From 16ea2153b40f90578268c3d1d5f160c47b63bc29 Mon Sep 17 00:00:00 2001 From: upstream Date: Fri, 2 Oct 2026 16:38:57 +0200 Subject: [PATCH] Upstream zed-industries/zed state of the touched files before 35f256f943 --- crates/editor/src/editor_tests.rs | 50209 ++++++++++++++++++++++++++++ crates/editor/src/selection.rs | 2792 ++ 2 files changed, 53001 insertions(+) create mode 100644 crates/editor/src/editor_tests.rs create mode 100644 crates/editor/src/selection.rs diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs new file mode 100644 index 0000000..917a70e --- /dev/null +++ b/crates/editor/src/editor_tests.rs @@ -0,0 +1,50209 @@ +use super::*; +use crate::{ + JoinLines, + code_context_menus::CodeContextMenu, + edit_prediction_tests::FakeEditPredictionDelegate, + element::{StickyHeader, header_jump_data}, + linked_editing_ranges::LinkedEditingRanges, + mouse_context_menu::MenuPosition, + runnables::RunnableTasks, + scroll::scroll_amount::ScrollAmount, + test::{ + assert_text_with_selections, build_editor, editor_content_with_blocks, + editor_lsp_test_context::{EditorLspTestContext, git_commit_lang}, + editor_test_context::EditorTestContext, + select_ranges, + }, +}; +use buffer_diff::{BufferDiff, DiffHunkSecondaryStatus, DiffHunkStatus, DiffHunkStatusKind}; +use collections::{HashMap, HashSet}; +use fs::Fs as _; +use futures::{StreamExt, channel::oneshot}; +use gpui::{ + BackgroundExecutor, DismissEvent, Task, TaskExt, TestAppContext, UpdateGlobal, + VisualTestContext, WindowBounds, WindowOptions, div, +}; +use indoc::{formatdoc, indoc}; +use language::{ + BracketPair, BracketPairConfig, + Capability::{Read, ReadOnly, ReadWrite}, + ContextLocation, ContextProvider, DiagnosticSourceKind, FakeLspAdapter, IndentGuideSettings, + LanguageConfig, LanguageConfigOverride, LanguageMatcher, LanguageName, LanguageQueries, + LanguageToolchainStore, Override, PLAIN_TEXT, Point, + language_settings::{ + CompletionSettingsContent, FormatOnSave, FormatterList, LanguageSettingsContent, + LspInsertMode, + }, + tree_sitter_python, +}; +use language_settings::Formatter; +use languages::{language, markdown_lang, rust_lang}; +use lsp::{CompletionParams, DEFAULT_LSP_REQUEST_TIMEOUT}; +use multi_buffer::{IndentGuide, MultiBuffer, MultiBufferOffset, MultiBufferOffsetUtf16, PathKey}; +use parking_lot::Mutex; +use pretty_assertions::{assert_eq, assert_ne}; +use project::{ + FakeFs, Project, ProjectPath, + bookmark_store::{BookmarkStore, BookmarkStoreEvent, SerializedBookmark}, + debugger::breakpoint_store::{BreakpointState, SourceBreakpoint}, + project_settings::LspSettings, + trusted_worktrees::{PathTrust, TrustedWorktrees}, +}; +use serde_json::{self, json}; +use settings::{ + AllLanguageSettingsContent, DelayMs, EditorSettingsContent, GlobalLspSettingsContent, + GoToDefinitionScrollStrategy, IndentGuideBackgroundColoring, IndentGuideColoring, + InlayHintSettingsContent, ProjectSettingsContent, ScrollBeyondLastLine, SearchSettingsContent, + SettingsContent, SettingsStore, +}; +use std::{ + borrow::Cow, + cmp::Ordering, + sync::{Arc, atomic}, +}; +use std::{cell::RefCell, future::Future, rc::Rc, sync::atomic::AtomicBool, time::Instant}; +use std::{iter, sync::atomic::AtomicUsize}; +use task::TaskVariables; +use test::build_editor_with_project; +use unindent::Unindent; +use util::{ + assert_set_eq, path, + rel_path::rel_path, + test::{TextRangeMarker, marked_text_ranges, marked_text_ranges_by, sample_text}, +}; +use workspace::{ + CloseActiveItem, CloseAllItems, CloseOtherItems, MultiWorkspace, NavigationEntry, OpenOptions, + Pane, SplitDirection, ToolbarItemLocation, ViewId, Workspace, + item::{FollowEvent, FollowableItem, Item, ItemHandle, SaveOptions}, + register_project_item, +}; + +fn display_ranges(editor: &Editor, cx: &mut Context<'_, Editor>) -> Vec> { + editor + .selections + .display_ranges(&editor.display_snapshot(cx)) +} + +#[gpui::test] +fn test_highlighted_display_rows_in_range(cx: &mut TestAppContext) { + struct FirstHighlight; + struct SecondHighlight; + + init_test(cx, |_| {}); + let buffer = cx.new(|cx| language::Buffer::local(sample_text(8, 3, 'a'), cx)); + let editor = cx.add_window(|window, cx| Editor::for_buffer(buffer, None, window, cx)); + + assert!( + editor + .update(cx, |editor, window, cx| { + let buffer = editor.buffer().read(cx).snapshot(cx); + editor.highlight_rows::( + buffer.anchor_before(Point::new(0, 0))..buffer.anchor_before(Point::new(6, 0)), + |cx| cx.theme().colors().editor_background, + RowHighlightOptions::default(), + cx, + ); + editor.highlight_rows::( + buffer.anchor_before(Point::new(0, 0))..buffer.anchor_before(Point::new(1, 0)), + |cx| cx.theme().colors().editor_highlighted_line_background, + RowHighlightOptions::default(), + cx, + ); + editor.highlight_rows::( + buffer.anchor_before(Point::new(3, 0))..buffer.anchor_before(Point::new(4, 0)), + |cx| cx.theme().colors().editor_highlighted_line_background, + RowHighlightOptions::default(), + cx, + ); + editor.highlight_rows::( + buffer.anchor_before(Point::new(6, 0))..buffer.anchor_before(Point::new(7, 0)), + |cx| cx.theme().colors().editor_highlighted_line_background, + RowHighlightOptions::default(), + cx, + ); + + let display_row_range = DisplayRow(2)..DisplayRow(5); + let expected = editor + .highlighted_display_rows(window, cx) + .into_iter() + .filter(|(row, _)| display_row_range.contains(row)) + .collect::>(); + let snapshot = editor.snapshot(window, cx); + let actual = editor.highlighted_display_rows_in_range( + buffer.anchor_before(Point::new(2, 0))..buffer.anchor_before(Point::new(5, 0)), + display_row_range, + &snapshot.display_snapshot, + cx, + ); + + assert_eq!(actual, expected); + + let highlight_start = buffer.anchor_before(Point::new(2, 0)); + let Some(block_id) = editor + .insert_blocks( + [BlockProperties { + style: BlockStyle::Fixed, + placement: BlockPlacement::Above(highlight_start), + height: Some(1), + render: Arc::new(|_| div().into_any()), + priority: 0, + }], + None, + cx, + ) + .into_iter() + .next() + else { + panic!("expected an inserted block"); + }; + editor.highlight_rows::( + highlight_start..buffer.anchor_before(Point::new(4, 0)), + |cx| cx.theme().colors().editor_highlighted_line_background, + RowHighlightOptions::default(), + cx, + ); + let Some(block_row) = editor.row_for_block(block_id, cx) else { + panic!("expected an inserted block row"); + }; + let display_row_range = block_row..block_row.next_row(); + let expected = editor + .highlighted_display_rows(window, cx) + .into_iter() + .filter(|(row, _)| display_row_range.contains(row)) + .collect::>(); + let snapshot = editor.snapshot(window, cx); + let actual = editor.highlighted_display_rows_in_range( + buffer.anchor_before(Point::new(1, 0))..highlight_start, + display_row_range, + &snapshot.display_snapshot, + cx, + ); + + assert!(!expected.is_empty()); + assert_eq!(actual, expected); + }) + .is_ok() + ); +} + +#[cfg(any(test, feature = "test-support"))] +pub mod property_test; + +#[gpui::test] +fn test_edit_events(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let buffer = cx.new(|cx| { + let mut buffer = language::Buffer::local("123456", cx); + buffer.set_group_interval(Duration::from_secs(1)); + buffer + }); + + let events = Rc::new(RefCell::new(Vec::new())); + let editor1 = cx.add_window({ + let events = events.clone(); + |window, cx| { + let entity = cx.entity(); + cx.subscribe_in( + &entity, + window, + move |_, _, event: &EditorEvent, _, _| match event { + EditorEvent::Edited { .. } => events.borrow_mut().push(("editor1", "edited")), + EditorEvent::BufferEdited => { + events.borrow_mut().push(("editor1", "buffer edited")) + } + _ => {} + }, + ) + .detach(); + Editor::for_buffer(buffer.clone(), None, window, cx) + } + }); + + let editor2 = cx.add_window({ + let events = events.clone(); + |window, cx| { + cx.subscribe_in( + &cx.entity(), + window, + move |_, _, event: &EditorEvent, _, _| match event { + EditorEvent::Edited { .. } => events.borrow_mut().push(("editor2", "edited")), + EditorEvent::BufferEdited => { + events.borrow_mut().push(("editor2", "buffer edited")) + } + _ => {} + }, + ) + .detach(); + Editor::for_buffer(buffer.clone(), None, window, cx) + } + }); + + assert_eq!(mem::take(&mut *events.borrow_mut()), []); + + // Mutating editor 1 will emit an `Edited` event only for that editor. + _ = editor1.update(cx, |editor, window, cx| editor.insert("X", window, cx)); + assert_eq!( + mem::take(&mut *events.borrow_mut()), + [ + ("editor1", "edited"), + ("editor1", "buffer edited"), + ("editor2", "buffer edited"), + ] + ); + + // Mutating editor 2 will emit an `Edited` event only for that editor. + _ = editor2.update(cx, |editor, window, cx| editor.delete(&Delete, window, cx)); + assert_eq!( + mem::take(&mut *events.borrow_mut()), + [ + ("editor2", "edited"), + ("editor1", "buffer edited"), + ("editor2", "buffer edited"), + ] + ); + + // Undoing on editor 1 will emit an `Edited` event only for that editor. + _ = editor1.update(cx, |editor, window, cx| editor.undo(&Undo, window, cx)); + assert_eq!( + mem::take(&mut *events.borrow_mut()), + [ + ("editor1", "edited"), + ("editor1", "buffer edited"), + ("editor2", "buffer edited"), + ] + ); + + // Redoing on editor 1 will emit an `Edited` event only for that editor. + _ = editor1.update(cx, |editor, window, cx| editor.redo(&Redo, window, cx)); + assert_eq!( + mem::take(&mut *events.borrow_mut()), + [ + ("editor1", "edited"), + ("editor1", "buffer edited"), + ("editor2", "buffer edited"), + ] + ); + + // Undoing on editor 2 will emit an `Edited` event only for that editor. + _ = editor2.update(cx, |editor, window, cx| editor.undo(&Undo, window, cx)); + assert_eq!( + mem::take(&mut *events.borrow_mut()), + [ + ("editor2", "edited"), + ("editor1", "buffer edited"), + ("editor2", "buffer edited"), + ] + ); + + // Redoing on editor 2 will emit an `Edited` event only for that editor. + _ = editor2.update(cx, |editor, window, cx| editor.redo(&Redo, window, cx)); + assert_eq!( + mem::take(&mut *events.borrow_mut()), + [ + ("editor2", "edited"), + ("editor1", "buffer edited"), + ("editor2", "buffer edited"), + ] + ); + + // No event is emitted when the mutation is a no-op. + _ = editor2.update(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_ranges([MultiBufferOffset(0)..MultiBufferOffset(0)]) + }); + + editor.backspace(&Backspace, window, cx); + }); + assert_eq!(mem::take(&mut *events.borrow_mut()), []); +} + +#[gpui::test] +fn test_undo_redo_with_selection_restoration(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut now = Instant::now(); + let group_interval = Duration::from_millis(1); + let buffer = cx.new(|cx| { + let mut buf = language::Buffer::local("123456", cx); + buf.set_group_interval(group_interval); + buf + }); + let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + let editor = cx.add_window(|window, cx| build_editor(buffer.clone(), window, cx)); + + _ = editor.update(cx, |editor, window, cx| { + editor.start_transaction_at(now, window, cx); + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_ranges([MultiBufferOffset(2)..MultiBufferOffset(4)]) + }); + + editor.insert("cd", window, cx); + editor.end_transaction_at(now, cx); + assert_eq!(editor.text(cx), "12cd56"); + assert_eq!( + editor.selections.ranges(&editor.display_snapshot(cx)), + vec![MultiBufferOffset(4)..MultiBufferOffset(4)] + ); + + editor.start_transaction_at(now, window, cx); + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_ranges([MultiBufferOffset(4)..MultiBufferOffset(5)]) + }); + editor.insert("e", window, cx); + editor.end_transaction_at(now, cx); + assert_eq!(editor.text(cx), "12cde6"); + assert_eq!( + editor.selections.ranges(&editor.display_snapshot(cx)), + vec![MultiBufferOffset(5)..MultiBufferOffset(5)] + ); + + now += group_interval + Duration::from_millis(1); + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_ranges([MultiBufferOffset(2)..MultiBufferOffset(2)]) + }); + + // Simulate an edit in another editor + buffer.update(cx, |buffer, cx| { + buffer.start_transaction_at(now, cx); + buffer.edit( + [(MultiBufferOffset(0)..MultiBufferOffset(1), "a")], + None, + cx, + ); + buffer.edit( + [(MultiBufferOffset(1)..MultiBufferOffset(1), "b")], + None, + cx, + ); + buffer.end_transaction_at(now, cx); + }); + + assert_eq!(editor.text(cx), "ab2cde6"); + assert_eq!( + editor.selections.ranges(&editor.display_snapshot(cx)), + vec![MultiBufferOffset(3)..MultiBufferOffset(3)] + ); + + // Last transaction happened past the group interval in a different editor. + // Undo it individually and don't restore selections. + editor.undo(&Undo, window, cx); + assert_eq!(editor.text(cx), "12cde6"); + assert_eq!( + editor.selections.ranges(&editor.display_snapshot(cx)), + vec![MultiBufferOffset(2)..MultiBufferOffset(2)] + ); + + // First two transactions happened within the group interval in this editor. + // Undo them together and restore selections. + editor.undo(&Undo, window, cx); + editor.undo(&Undo, window, cx); // Undo stack is empty here, so this is a no-op. + assert_eq!(editor.text(cx), "123456"); + assert_eq!( + editor.selections.ranges(&editor.display_snapshot(cx)), + vec![MultiBufferOffset(0)..MultiBufferOffset(0)] + ); + + // Redo the first two transactions together. + editor.redo(&Redo, window, cx); + assert_eq!(editor.text(cx), "12cde6"); + assert_eq!( + editor.selections.ranges(&editor.display_snapshot(cx)), + vec![MultiBufferOffset(5)..MultiBufferOffset(5)] + ); + + // Redo the last transaction on its own. + editor.redo(&Redo, window, cx); + assert_eq!(editor.text(cx), "ab2cde6"); + assert_eq!( + editor.selections.ranges(&editor.display_snapshot(cx)), + vec![MultiBufferOffset(6)..MultiBufferOffset(6)] + ); + + // Test empty transactions. + editor.start_transaction_at(now, window, cx); + editor.end_transaction_at(now, cx); + editor.undo(&Undo, window, cx); + assert_eq!(editor.text(cx), "12cde6"); + }); +} + +#[gpui::test] +fn test_undo_redo_with_empty_history_selections_does_not_panic(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let now = Instant::now(); + let buffer = cx.new(|cx| language::Buffer::local("123456", cx)); + let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + let editor = cx.add_window(|window, cx| build_editor(buffer, window, cx)); + + _ = editor.update(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_ranges([MultiBufferOffset(0)..MultiBufferOffset(0)]) + }); + editor.start_transaction_at(now, window, cx); + editor.insert("a", window, cx); + let transaction_id = editor + .end_transaction_at(now, cx) + .expect("transaction should be created"); + assert_eq!(editor.text(cx), "a123456"); + + editor.modify_transaction_selection_history(transaction_id, |selections| { + selections.undo = Vec::new().into(); + selections.redo = Some(Vec::new().into()); + }); + + editor.undo(&Undo, window, cx); + assert_eq!(editor.text(cx), "123456"); + + editor.redo(&Redo, window, cx); + assert_eq!(editor.text(cx), "a123456"); + }); +} + +#[gpui::test] +fn test_accessibility_keyboard_word_completion(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + // Simulates the macOS Accessibility Keyboard word completion panel, which calls + // insertText:replacementRange: to commit a completion. macOS sends two calls per + // completion: one with a non-empty range replacing the typed prefix, and one with + // an empty replacement range (cursor..cursor) to append a trailing space. + + cx.add_window(|window, cx| { + let buffer = MultiBuffer::build_simple("ab", cx); + let mut editor = build_editor(buffer, window, cx); + + // Cursor is after the 2-char prefix "ab" at offset 2. + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_ranges([MultiBufferOffset(2)..MultiBufferOffset(2)]) + }); + + // macOS completes "about" by replacing the prefix via range 0..2. + editor.replace_text_in_range(Some(0..2), "about", window, cx); + assert_eq!(editor.text(cx), "about"); + + // macOS sends a trailing space as an empty replacement range (cursor..cursor). + // Must insert at the cursor position, not call backspace first (which would + // delete the preceding character). + editor.replace_text_in_range(Some(5..5), " ", window, cx); + assert_eq!(editor.text(cx), "about "); + + editor + }); + + // Multi-cursor: the replacement must fan out to all cursors, and the trailing + // space must land at each cursor's actual current position. After the first + // completion, macOS's reported cursor offset is stale (it doesn't account for + // the offset shift caused by the other cursor's insertion), so the empty + // replacement range must be ignored and the space inserted at each real cursor. + cx.add_window(|window, cx| { + // Two cursors, each after a 2-char prefix "ab" at the end of each line: + // "ab\nab" — cursors at offsets 2 and 5. + let buffer = MultiBuffer::build_simple("ab\nab", cx); + let mut editor = build_editor(buffer, window, cx); + + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_ranges([ + MultiBufferOffset(2)..MultiBufferOffset(2), + MultiBufferOffset(5)..MultiBufferOffset(5), + ]) + }); + + // macOS reports the newest cursor (offset 5) and sends range 3..5 to + // replace its 2-char prefix. selection_replacement_ranges applies the same + // delta to fan out to both cursors: 0..2 and 3..5. + editor.replace_text_in_range(Some(3..5), "about", window, cx); + assert_eq!(editor.text(cx), "about\nabout"); + + // Trailing space via empty range. macOS thinks the cursor is at offset 10 + // (5 - 2 + 7 = 10), but the actual cursors are at 5 and 11. The stale + // offset must be ignored and the space inserted at each real cursor position. + editor.replace_text_in_range(Some(10..10), " ", window, cx); + assert_eq!(editor.text(cx), "about \nabout "); + + editor + }); +} + +#[gpui::test] +fn test_ime_composition(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let buffer = cx.new(|cx| { + let mut buffer = language::Buffer::local("abcde", cx); + // Ensure automatic grouping doesn't occur. + buffer.set_group_interval(Duration::ZERO); + buffer + }); + + let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + cx.add_window(|window, cx| { + let mut editor = build_editor(buffer.clone(), window, cx); + + // Start a new IME composition. + editor.replace_and_mark_text_in_range(Some(0..1), "à", None, window, cx); + editor.replace_and_mark_text_in_range(Some(0..1), "á", None, window, cx); + editor.replace_and_mark_text_in_range(Some(0..1), "ä", None, window, cx); + assert_eq!(editor.text(cx), "äbcde"); + assert_eq!( + editor.marked_text_ranges(cx), + Some(vec![ + MultiBufferOffsetUtf16(OffsetUtf16(0))..MultiBufferOffsetUtf16(OffsetUtf16(1)) + ]) + ); + + // Finalize IME composition. + editor.replace_text_in_range(None, "ā", window, cx); + assert_eq!(editor.text(cx), "ābcde"); + assert_eq!(editor.marked_text_ranges(cx), None); + + // IME composition edits are grouped and are undone/redone at once. + editor.undo(&Default::default(), window, cx); + assert_eq!(editor.text(cx), "abcde"); + assert_eq!(editor.marked_text_ranges(cx), None); + editor.redo(&Default::default(), window, cx); + assert_eq!(editor.text(cx), "ābcde"); + assert_eq!(editor.marked_text_ranges(cx), None); + + // Start a new IME composition. + editor.replace_and_mark_text_in_range(Some(0..1), "à", None, window, cx); + assert_eq!( + editor.marked_text_ranges(cx), + Some(vec![ + MultiBufferOffsetUtf16(OffsetUtf16(0))..MultiBufferOffsetUtf16(OffsetUtf16(1)) + ]) + ); + + // Undoing during an IME composition cancels it. + editor.undo(&Default::default(), window, cx); + assert_eq!(editor.text(cx), "ābcde"); + assert_eq!(editor.marked_text_ranges(cx), None); + + // Start a new IME composition with an invalid marked range, ensuring it gets clipped. + editor.replace_and_mark_text_in_range(Some(4..999), "è", None, window, cx); + assert_eq!(editor.text(cx), "ābcdè"); + assert_eq!( + editor.marked_text_ranges(cx), + Some(vec![ + MultiBufferOffsetUtf16(OffsetUtf16(4))..MultiBufferOffsetUtf16(OffsetUtf16(5)) + ]) + ); + + // Finalize IME composition with an invalid replacement range, ensuring it gets clipped. + editor.replace_text_in_range(Some(4..999), "ę", window, cx); + assert_eq!(editor.text(cx), "ābcdę"); + assert_eq!(editor.marked_text_ranges(cx), None); + + // Start a new IME composition with multiple cursors. + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_ranges([ + MultiBufferOffsetUtf16(OffsetUtf16(1))..MultiBufferOffsetUtf16(OffsetUtf16(1)), + MultiBufferOffsetUtf16(OffsetUtf16(3))..MultiBufferOffsetUtf16(OffsetUtf16(3)), + MultiBufferOffsetUtf16(OffsetUtf16(5))..MultiBufferOffsetUtf16(OffsetUtf16(5)), + ]) + }); + editor.replace_and_mark_text_in_range(Some(4..5), "XYZ", None, window, cx); + assert_eq!(editor.text(cx), "XYZbXYZdXYZ"); + assert_eq!( + editor.marked_text_ranges(cx), + Some(vec![ + MultiBufferOffsetUtf16(OffsetUtf16(0))..MultiBufferOffsetUtf16(OffsetUtf16(3)), + MultiBufferOffsetUtf16(OffsetUtf16(4))..MultiBufferOffsetUtf16(OffsetUtf16(7)), + MultiBufferOffsetUtf16(OffsetUtf16(8))..MultiBufferOffsetUtf16(OffsetUtf16(11)) + ]) + ); + + // Ensure the newly-marked range gets treated as relative to the previously-marked ranges. + editor.replace_and_mark_text_in_range(Some(1..2), "1", None, window, cx); + assert_eq!(editor.text(cx), "X1ZbX1ZdX1Z"); + assert_eq!( + editor.marked_text_ranges(cx), + Some(vec![ + MultiBufferOffsetUtf16(OffsetUtf16(1))..MultiBufferOffsetUtf16(OffsetUtf16(2)), + MultiBufferOffsetUtf16(OffsetUtf16(5))..MultiBufferOffsetUtf16(OffsetUtf16(6)), + MultiBufferOffsetUtf16(OffsetUtf16(9))..MultiBufferOffsetUtf16(OffsetUtf16(10)) + ]) + ); + + // Finalize IME composition with multiple cursors. + editor.replace_text_in_range(Some(9..10), "2", window, cx); + assert_eq!(editor.text(cx), "X2ZbX2ZdX2Z"); + assert_eq!(editor.marked_text_ranges(cx), None); + + editor + }); +} + +#[gpui::test] +fn test_selection_with_mouse(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let editor = cx.add_window(|window, cx| { + let buffer = MultiBuffer::build_simple("aaaaaa\nbbbbbb\ncccccc\nddddddd\n", cx); + build_editor(buffer, window, cx) + }); + + _ = editor.update(cx, |editor, window, cx| { + editor.begin_selection(DisplayPoint::new(DisplayRow(2), 2), false, 1, window, cx); + }); + assert_eq!( + editor + .update(cx, |editor, _, cx| display_ranges(editor, cx)) + .unwrap(), + [DisplayPoint::new(DisplayRow(2), 2)..DisplayPoint::new(DisplayRow(2), 2)] + ); + + _ = editor.update(cx, |editor, window, cx| { + editor.update_selection( + DisplayPoint::new(DisplayRow(3), 3), + 0, + gpui::Point::::default(), + window, + cx, + ); + }); + + assert_eq!( + editor + .update(cx, |editor, _, cx| display_ranges(editor, cx)) + .unwrap(), + [DisplayPoint::new(DisplayRow(2), 2)..DisplayPoint::new(DisplayRow(3), 3)] + ); + + _ = editor.update(cx, |editor, window, cx| { + editor.update_selection( + DisplayPoint::new(DisplayRow(1), 1), + 0, + gpui::Point::::default(), + window, + cx, + ); + }); + + assert_eq!( + editor + .update(cx, |editor, _, cx| display_ranges(editor, cx)) + .unwrap(), + [DisplayPoint::new(DisplayRow(2), 2)..DisplayPoint::new(DisplayRow(1), 1)] + ); + + _ = editor.update(cx, |editor, window, cx| { + editor.end_selection(window, cx); + editor.update_selection( + DisplayPoint::new(DisplayRow(3), 3), + 0, + gpui::Point::::default(), + window, + cx, + ); + }); + + assert_eq!( + editor + .update(cx, |editor, _, cx| display_ranges(editor, cx)) + .unwrap(), + [DisplayPoint::new(DisplayRow(2), 2)..DisplayPoint::new(DisplayRow(1), 1)] + ); + + _ = editor.update(cx, |editor, window, cx| { + editor.begin_selection(DisplayPoint::new(DisplayRow(3), 3), true, 1, window, cx); + editor.update_selection( + DisplayPoint::new(DisplayRow(0), 0), + 0, + gpui::Point::::default(), + window, + cx, + ); + }); + + assert_eq!( + editor + .update(cx, |editor, _, cx| display_ranges(editor, cx)) + .unwrap(), + [ + DisplayPoint::new(DisplayRow(2), 2)..DisplayPoint::new(DisplayRow(1), 1), + DisplayPoint::new(DisplayRow(3), 3)..DisplayPoint::new(DisplayRow(0), 0) + ] + ); + + _ = editor.update(cx, |editor, window, cx| { + editor.end_selection(window, cx); + }); + + assert_eq!( + editor + .update(cx, |editor, _, cx| display_ranges(editor, cx)) + .unwrap(), + [DisplayPoint::new(DisplayRow(3), 3)..DisplayPoint::new(DisplayRow(0), 0)] + ); +} + +#[gpui::test] +fn test_multiple_cursor_removal(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let editor = cx.add_window(|window, cx| { + let buffer = MultiBuffer::build_simple("aaaaaa\nbbbbbb\ncccccc\nddddddd\n", cx); + build_editor(buffer, window, cx) + }); + + _ = editor.update(cx, |editor, window, cx| { + editor.begin_selection(DisplayPoint::new(DisplayRow(2), 1), false, 1, window, cx); + }); + + _ = editor.update(cx, |editor, window, cx| { + editor.end_selection(window, cx); + }); + + _ = editor.update(cx, |editor, window, cx| { + editor.begin_selection(DisplayPoint::new(DisplayRow(3), 2), true, 1, window, cx); + }); + + _ = editor.update(cx, |editor, window, cx| { + editor.end_selection(window, cx); + }); + + assert_eq!( + editor + .update(cx, |editor, _, cx| display_ranges(editor, cx)) + .unwrap(), + [ + DisplayPoint::new(DisplayRow(2), 1)..DisplayPoint::new(DisplayRow(2), 1), + DisplayPoint::new(DisplayRow(3), 2)..DisplayPoint::new(DisplayRow(3), 2) + ] + ); + + _ = editor.update(cx, |editor, window, cx| { + editor.begin_selection(DisplayPoint::new(DisplayRow(2), 1), true, 1, window, cx); + }); + + _ = editor.update(cx, |editor, window, cx| { + editor.end_selection(window, cx); + }); + + assert_eq!( + editor + .update(cx, |editor, _, cx| display_ranges(editor, cx)) + .unwrap(), + [DisplayPoint::new(DisplayRow(3), 2)..DisplayPoint::new(DisplayRow(3), 2)] + ); +} + +#[gpui::test] +fn test_canceling_pending_selection(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let editor = cx.add_window(|window, cx| { + let buffer = MultiBuffer::build_simple("aaaaaa\nbbbbbb\ncccccc\ndddddd\n", cx); + build_editor(buffer, window, cx) + }); + + _ = editor.update(cx, |editor, window, cx| { + editor.begin_selection(DisplayPoint::new(DisplayRow(2), 2), false, 1, window, cx); + assert_eq!( + display_ranges(editor, cx), + [DisplayPoint::new(DisplayRow(2), 2)..DisplayPoint::new(DisplayRow(2), 2)] + ); + }); + + _ = editor.update(cx, |editor, window, cx| { + editor.update_selection( + DisplayPoint::new(DisplayRow(3), 3), + 0, + gpui::Point::::default(), + window, + cx, + ); + assert_eq!( + display_ranges(editor, cx), + [DisplayPoint::new(DisplayRow(2), 2)..DisplayPoint::new(DisplayRow(3), 3)] + ); + }); + + _ = editor.update(cx, |editor, window, cx| { + editor.cancel(&Cancel, window, cx); + editor.update_selection( + DisplayPoint::new(DisplayRow(1), 1), + 0, + gpui::Point::::default(), + window, + cx, + ); + assert_eq!( + display_ranges(editor, cx), + [DisplayPoint::new(DisplayRow(2), 2)..DisplayPoint::new(DisplayRow(3), 3)] + ); + }); +} + +#[gpui::test] +fn test_movement_actions_with_pending_selection(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let editor = cx.add_window(|window, cx| { + let buffer = MultiBuffer::build_simple("aaaaaa\nbbbbbb\ncccccc\ndddddd\n", cx); + build_editor(buffer, window, cx) + }); + + _ = editor.update(cx, |editor, window, cx| { + editor.begin_selection(DisplayPoint::new(DisplayRow(2), 2), false, 1, window, cx); + assert_eq!( + display_ranges(editor, cx), + [DisplayPoint::new(DisplayRow(2), 2)..DisplayPoint::new(DisplayRow(2), 2)] + ); + + editor.move_down(&Default::default(), window, cx); + assert_eq!( + display_ranges(editor, cx), + [DisplayPoint::new(DisplayRow(3), 2)..DisplayPoint::new(DisplayRow(3), 2)] + ); + + editor.begin_selection(DisplayPoint::new(DisplayRow(2), 2), false, 1, window, cx); + assert_eq!( + display_ranges(editor, cx), + [DisplayPoint::new(DisplayRow(2), 2)..DisplayPoint::new(DisplayRow(2), 2)] + ); + + editor.move_up(&Default::default(), window, cx); + assert_eq!( + display_ranges(editor, cx), + [DisplayPoint::new(DisplayRow(1), 2)..DisplayPoint::new(DisplayRow(1), 2)] + ); + }); +} + +#[gpui::test] +fn test_extending_selection(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let editor = cx.add_window(|window, cx| { + let buffer = MultiBuffer::build_simple("aaa bbb ccc ddd eee", cx); + build_editor(buffer, window, cx) + }); + + _ = editor.update(cx, |editor, window, cx| { + editor.begin_selection(DisplayPoint::new(DisplayRow(0), 5), false, 1, window, cx); + editor.end_selection(window, cx); + assert_eq!( + display_ranges(editor, cx), + [DisplayPoint::new(DisplayRow(0), 5)..DisplayPoint::new(DisplayRow(0), 5)] + ); + + editor.extend_selection(DisplayPoint::new(DisplayRow(0), 10), 1, window, cx); + editor.end_selection(window, cx); + assert_eq!( + display_ranges(editor, cx), + [DisplayPoint::new(DisplayRow(0), 5)..DisplayPoint::new(DisplayRow(0), 10)] + ); + + editor.extend_selection(DisplayPoint::new(DisplayRow(0), 10), 1, window, cx); + editor.end_selection(window, cx); + editor.extend_selection(DisplayPoint::new(DisplayRow(0), 10), 2, window, cx); + assert_eq!( + display_ranges(editor, cx), + [DisplayPoint::new(DisplayRow(0), 5)..DisplayPoint::new(DisplayRow(0), 11)] + ); + + editor.update_selection( + DisplayPoint::new(DisplayRow(0), 1), + 0, + gpui::Point::::default(), + window, + cx, + ); + editor.end_selection(window, cx); + assert_eq!( + display_ranges(editor, cx), + [DisplayPoint::new(DisplayRow(0), 5)..DisplayPoint::new(DisplayRow(0), 0)] + ); + + editor.begin_selection(DisplayPoint::new(DisplayRow(0), 5), true, 1, window, cx); + editor.end_selection(window, cx); + editor.begin_selection(DisplayPoint::new(DisplayRow(0), 5), true, 2, window, cx); + editor.end_selection(window, cx); + assert_eq!( + display_ranges(editor, cx), + [DisplayPoint::new(DisplayRow(0), 4)..DisplayPoint::new(DisplayRow(0), 7)] + ); + + editor.extend_selection(DisplayPoint::new(DisplayRow(0), 10), 1, window, cx); + assert_eq!( + display_ranges(editor, cx), + [DisplayPoint::new(DisplayRow(0), 4)..DisplayPoint::new(DisplayRow(0), 11)] + ); + + editor.update_selection( + DisplayPoint::new(DisplayRow(0), 6), + 0, + gpui::Point::::default(), + window, + cx, + ); + assert_eq!( + display_ranges(editor, cx), + [DisplayPoint::new(DisplayRow(0), 4)..DisplayPoint::new(DisplayRow(0), 7)] + ); + + editor.update_selection( + DisplayPoint::new(DisplayRow(0), 1), + 0, + gpui::Point::::default(), + window, + cx, + ); + editor.end_selection(window, cx); + assert_eq!( + display_ranges(editor, cx), + [DisplayPoint::new(DisplayRow(0), 7)..DisplayPoint::new(DisplayRow(0), 0)] + ); + }); +} + +#[gpui::test] +fn test_extend_selection_after_collapsed_word_selection(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let editor = cx.add_window(|window, cx| { + let buffer = MultiBuffer::build_simple("aaa bbb ccc ddd eee", cx); + build_editor(buffer, window, cx) + }); + + _ = editor.update(cx, |editor, window, cx| { + // Double-click "bbb" to select it word-wise. + editor.begin_selection(DisplayPoint::new(DisplayRow(0), 5), false, 2, window, cx); + editor.end_selection(window, cx); + assert_eq!( + display_ranges(editor, cx), + [DisplayPoint::new(DisplayRow(0), 4)..DisplayPoint::new(DisplayRow(0), 7)] + ); + + // Move the cursor with the keyboard, collapsing the selection. + editor.move_right(&MoveRight, window, cx); + assert_eq!( + display_ranges(editor, cx), + [DisplayPoint::new(DisplayRow(0), 7)..DisplayPoint::new(DisplayRow(0), 7)] + ); + + // Shift+click further along the line should extend from the cursor, + // not from the stale double-clicked word. + editor.extend_selection(DisplayPoint::new(DisplayRow(0), 10), 1, window, cx); + assert_eq!( + display_ranges(editor, cx), + [DisplayPoint::new(DisplayRow(0), 7)..DisplayPoint::new(DisplayRow(0), 10)] + ); + }); +} + +#[gpui::test] +fn test_extend_selection_after_collapsed_word_selection_moved_via_offsets(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let editor = cx.add_window(|window, cx| { + let buffer = MultiBuffer::build_simple("aaa bbb ccc ddd eee", cx); + build_editor(buffer, window, cx) + }); + + _ = editor.update(cx, |editor, window, cx| { + // Double-click "bbb" to select it word-wise. + editor.begin_selection(DisplayPoint::new(DisplayRow(0), 5), false, 2, window, cx); + editor.end_selection(window, cx); + assert_eq!( + display_ranges(editor, cx), + [DisplayPoint::new(DisplayRow(0), 4)..DisplayPoint::new(DisplayRow(0), 7)] + ); + + // Collapse the selection through `move_offsets_with`, the code path + // used by non-keyboard-movement callers like + // `move_to_enclosing_bracket` and `select_delimiters_impl`. + editor.change_selections(SelectionEffects::default(), window, cx, |s| { + s.move_offsets_with(&mut |_, selection| { + selection.collapse_to(MultiBufferOffset(7), SelectionGoal::None); + }); + }); + assert_eq!( + display_ranges(editor, cx), + [DisplayPoint::new(DisplayRow(0), 7)..DisplayPoint::new(DisplayRow(0), 7)] + ); + + // Shift+click further along the line should extend from the cursor, + // not from the stale double-clicked word. + editor.extend_selection(DisplayPoint::new(DisplayRow(0), 10), 1, window, cx); + assert_eq!( + display_ranges(editor, cx), + [DisplayPoint::new(DisplayRow(0), 7)..DisplayPoint::new(DisplayRow(0), 10)] + ); + }); +} + +#[gpui::test] +fn test_extend_selection_from_empty_line_selection(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let editor = cx.add_window(|window, cx| { + let buffer = MultiBuffer::build_simple("aaa\nbbb\n", cx); + build_editor(buffer, window, cx) + }); + + _ = editor.update(cx, |editor, window, cx| { + editor.begin_selection(DisplayPoint::new(DisplayRow(2), 0), false, 3, window, cx); + editor.end_selection(window, cx); + assert_eq!( + display_ranges(editor, cx), + [DisplayPoint::new(DisplayRow(2), 0)..DisplayPoint::new(DisplayRow(2), 0)] + ); + + editor.extend_selection(DisplayPoint::new(DisplayRow(0), 1), 1, window, cx); + assert_eq!( + display_ranges(editor, cx), + [DisplayPoint::new(DisplayRow(2), 0)..DisplayPoint::new(DisplayRow(0), 0)] + ); + }); +} + +#[gpui::test] +fn test_clone(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let (text, selection_ranges) = marked_text_ranges( + indoc! {" + one + two + threeˇ + four + fiveˇ + "}, + true, + ); + + let editor = cx.add_window(|window, cx| { + let buffer = MultiBuffer::build_simple(&text, cx); + build_editor(buffer, window, cx) + }); + + _ = editor.update(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_ranges( + selection_ranges + .iter() + .map(|range| MultiBufferOffset(range.start)..MultiBufferOffset(range.end)), + ) + }); + editor.fold_creases( + vec![ + Crease::simple(Point::new(1, 0)..Point::new(2, 0), FoldPlaceholder::test()), + Crease::simple(Point::new(3, 0)..Point::new(4, 0), FoldPlaceholder::test()), + ], + true, + window, + cx, + ); + }); + + let cloned_editor = editor + .update(cx, |editor, _, cx| { + cx.open_window(Default::default(), |window, cx| { + cx.new(|cx| editor.clone(window, cx)) + }) + }) + .unwrap() + .unwrap(); + + let snapshot = editor + .update(cx, |e, window, cx| e.snapshot(window, cx)) + .unwrap(); + let cloned_snapshot = cloned_editor + .update(cx, |e, window, cx| e.snapshot(window, cx)) + .unwrap(); + + assert_eq!( + cloned_editor + .update(cx, |e, _, cx| e.display_text(cx)) + .unwrap(), + editor.update(cx, |e, _, cx| e.display_text(cx)).unwrap() + ); + assert_eq!( + cloned_snapshot + .folds_in_range(MultiBufferOffset(0)..MultiBufferOffset(text.len())) + .collect::>(), + snapshot + .folds_in_range(MultiBufferOffset(0)..MultiBufferOffset(text.len())) + .collect::>(), + ); + assert_set_eq!( + cloned_editor + .update(cx, |editor, _, cx| editor + .selections + .ranges::(&editor.display_snapshot(cx))) + .unwrap(), + editor + .update(cx, |editor, _, cx| editor + .selections + .ranges(&editor.display_snapshot(cx))) + .unwrap() + ); + assert_set_eq!( + cloned_editor + .update(cx, |e, _window, cx| e + .selections + .display_ranges(&e.display_snapshot(cx))) + .unwrap(), + editor + .update(cx, |e, _, cx| e + .selections + .display_ranges(&e.display_snapshot(cx))) + .unwrap() + ); +} + +#[gpui::test] +fn test_toggle_breadcrumb_does_not_change_settings(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + update_test_editor_settings(cx, &|settings| { + settings.toolbar.get_or_insert_default().breadcrumbs = Some(true); + }); + + let editor = cx.add_window(|window, cx| { + let buffer = MultiBuffer::build_simple("hello", cx); + build_editor(buffer, window, cx) + }); + + _ = editor.update(cx, |editor, window, cx| { + assert!(EditorSettings::get_global(cx).toolbar.breadcrumbs); + assert_eq!( + editor.breadcrumb_location(cx), + ToolbarItemLocation::PrimaryLeft + ); + + editor.toggle_breadcrumb(&ToggleBreadcrumb, window, cx); + assert!(EditorSettings::get_global(cx).toolbar.breadcrumbs); + assert_eq!(editor.breadcrumb_location(cx), ToolbarItemLocation::Hidden); + }); + + // Changing unrelated settings should not affect breadcrumbs visibility. + update_test_editor_settings(cx, &|settings| { + settings.vertical_scroll_margin = Some(4.0); + }); + cx.run_until_parked(); + + _ = editor.update(cx, |editor, window, cx| { + assert!(EditorSettings::get_global(cx).toolbar.breadcrumbs); + assert_eq!(editor.breadcrumb_location(cx), ToolbarItemLocation::Hidden); + + editor.toggle_breadcrumb(&ToggleBreadcrumb, window, cx); + assert!(EditorSettings::get_global(cx).toolbar.breadcrumbs); + assert_eq!( + editor.breadcrumb_location(cx), + ToolbarItemLocation::PrimaryLeft + ); + }); +} + +#[gpui::test] +async fn test_navigation_history(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + use workspace::item::Item; + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [], cx).await; + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone()); + + _ = window.update(cx, |_mw, window, cx| { + cx.new(|cx| { + let buffer = MultiBuffer::build_simple(&sample_text(300, 5, 'a'), cx); + let mut editor = build_editor(buffer, window, cx); + let handle = cx.entity(); + editor.set_nav_history(Some(pane.read(cx).nav_history_for_item(&handle))); + + fn pop_history(editor: &mut Editor, cx: &mut App) -> Option { + editor.nav_history.as_mut().unwrap().pop_backward(cx) + } + + // Move the cursor a small distance. + // Nothing is added to the navigation history. + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(1), 0)..DisplayPoint::new(DisplayRow(1), 0) + ]) + }); + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(3), 0)..DisplayPoint::new(DisplayRow(3), 0) + ]) + }); + assert!(pop_history(&mut editor, cx).is_none()); + + // Move the cursor a large distance. + // The history can jump back to the previous position. + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(13), 0)..DisplayPoint::new(DisplayRow(13), 3) + ]) + }); + let nav_entry = pop_history(&mut editor, cx).unwrap(); + editor.navigate(nav_entry.data.unwrap(), window, cx); + assert_eq!(nav_entry.item.id(), cx.entity_id()); + assert_eq!( + editor + .selections + .display_ranges(&editor.display_snapshot(cx)), + &[DisplayPoint::new(DisplayRow(3), 0)..DisplayPoint::new(DisplayRow(3), 0)] + ); + assert!(pop_history(&mut editor, cx).is_none()); + + // Move the cursor a small distance via the mouse. + // Nothing is added to the navigation history. + editor.begin_selection(DisplayPoint::new(DisplayRow(5), 0), false, 1, window, cx); + editor.end_selection(window, cx); + assert_eq!( + editor + .selections + .display_ranges(&editor.display_snapshot(cx)), + &[DisplayPoint::new(DisplayRow(5), 0)..DisplayPoint::new(DisplayRow(5), 0)] + ); + assert!(pop_history(&mut editor, cx).is_none()); + + // Move the cursor a large distance via the mouse. + // The history can jump back to the previous position. + editor.begin_selection(DisplayPoint::new(DisplayRow(15), 0), false, 1, window, cx); + editor.end_selection(window, cx); + assert_eq!( + editor + .selections + .display_ranges(&editor.display_snapshot(cx)), + &[DisplayPoint::new(DisplayRow(15), 0)..DisplayPoint::new(DisplayRow(15), 0)] + ); + let nav_entry = pop_history(&mut editor, cx).unwrap(); + editor.navigate(nav_entry.data.unwrap(), window, cx); + assert_eq!(nav_entry.item.id(), cx.entity_id()); + assert_eq!( + editor + .selections + .display_ranges(&editor.display_snapshot(cx)), + &[DisplayPoint::new(DisplayRow(5), 0)..DisplayPoint::new(DisplayRow(5), 0)] + ); + assert!(pop_history(&mut editor, cx).is_none()); + + // Set scroll position to check later + editor.set_scroll_position(gpui::Point::::new(5.5, 5.5), window, cx); + let original_scroll_position = editor + .scroll_manager + .native_anchor(&editor.display_snapshot(cx), cx); + + // Jump to the end of the document and adjust scroll + editor.move_to_end(&MoveToEnd, window, cx); + editor.set_scroll_position(gpui::Point::::new(-2.5, -0.5), window, cx); + assert_ne!( + editor + .scroll_manager + .native_anchor(&editor.display_snapshot(cx), cx), + original_scroll_position + ); + + let nav_entry = pop_history(&mut editor, cx).unwrap(); + editor.navigate(nav_entry.data.unwrap(), window, cx); + assert_eq!( + editor + .scroll_manager + .native_anchor(&editor.display_snapshot(cx), cx), + original_scroll_position + ); + + let other_buffer = + cx.new(|cx| MultiBuffer::singleton(cx.new(|cx| Buffer::local("test", cx)), cx)); + + // Ensure we don't panic when navigation data contains invalid anchors *and* points. + let invalid_anchor = other_buffer.update(cx, |buffer, cx| { + buffer.snapshot(cx).anchor_after(MultiBufferOffset(3)) + }); + let invalid_point = Point::new(9999, 0); + editor.navigate( + Arc::new(NavigationData { + cursor_anchor: invalid_anchor, + cursor_position: invalid_point, + scroll_anchor: ScrollAnchor { + anchor: invalid_anchor, + offset: Default::default(), + }, + scroll_top_row: invalid_point.row, + }), + window, + cx, + ); + assert_eq!( + editor + .selections + .display_ranges(&editor.display_snapshot(cx)), + &[editor.max_point(cx)..editor.max_point(cx)] + ); + assert_eq!( + editor.scroll_position(cx), + gpui::Point::new(0., editor.max_point(cx).row().as_f64()) + ); + + editor + }) + }); +} + +#[gpui::test] +fn test_cancel(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let editor = cx.add_window(|window, cx| { + let buffer = MultiBuffer::build_simple("aaaaaa\nbbbbbb\ncccccc\ndddddd\n", cx); + build_editor(buffer, window, cx) + }); + + _ = editor.update(cx, |editor, window, cx| { + editor.begin_selection(DisplayPoint::new(DisplayRow(3), 4), false, 1, window, cx); + editor.update_selection( + DisplayPoint::new(DisplayRow(1), 1), + 0, + gpui::Point::::default(), + window, + cx, + ); + editor.end_selection(window, cx); + + editor.begin_selection(DisplayPoint::new(DisplayRow(0), 1), true, 1, window, cx); + editor.update_selection( + DisplayPoint::new(DisplayRow(0), 3), + 0, + gpui::Point::::default(), + window, + cx, + ); + editor.end_selection(window, cx); + assert_eq!( + display_ranges(editor, cx), + [ + DisplayPoint::new(DisplayRow(0), 1)..DisplayPoint::new(DisplayRow(0), 3), + DisplayPoint::new(DisplayRow(3), 4)..DisplayPoint::new(DisplayRow(1), 1), + ] + ); + }); + + _ = editor.update(cx, |editor, window, cx| { + editor.cancel(&Cancel, window, cx); + assert_eq!( + display_ranges(editor, cx), + [DisplayPoint::new(DisplayRow(3), 4)..DisplayPoint::new(DisplayRow(1), 1)] + ); + }); + + _ = editor.update(cx, |editor, window, cx| { + editor.cancel(&Cancel, window, cx); + assert_eq!( + display_ranges(editor, cx), + [DisplayPoint::new(DisplayRow(1), 1)..DisplayPoint::new(DisplayRow(1), 1)] + ); + }); +} + +#[gpui::test] +async fn test_fold_action(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + cx.update_buffer(|buffer, cx| buffer.set_language(Some(rust_lang()), cx)); + cx.set_state(indoc! {" + impl Foo { + // Hello! + + fn a() { + 1 + } + + fn b() { + 2 + } + + fn c() { + 3 + } + }ˇ + "}); + + cx.update_editor(|editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(7), 0)..DisplayPoint::new(DisplayRow(12), 0) + ]); + }); + editor.fold(&Fold, window, cx); + assert_eq!( + editor.display_text(cx), + " + impl Foo { + // Hello! + + fn a() { + 1 + } + + fn b() {⋯} + + fn c() {⋯} + } + " + .unindent(), + ); + + editor.fold(&Fold, window, cx); + assert_eq!( + editor.display_text(cx), + " + impl Foo {⋯} + " + .unindent(), + ); + + editor.unfold_lines(&UnfoldLines, window, cx); + assert_eq!( + editor.display_text(cx), + " + impl Foo { + // Hello! + + fn a() { + 1 + } + + fn b() {⋯} + + fn c() {⋯} + } + " + .unindent(), + ); + + editor.unfold_lines(&UnfoldLines, window, cx); + assert_eq!( + editor.display_text(cx), + editor.buffer.read(cx).read(cx).text() + ); + }); +} + +#[gpui::test] +fn test_fold_action_without_language(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let editor = cx.add_window(|window, cx| { + let buffer = MultiBuffer::build_simple( + &" + impl Foo { + // Hello! + + fn a() { + 1 + } + + fn b() { + 2 + } + + fn c() { + 3 + } + } + " + .unindent(), + cx, + ); + build_editor(buffer, window, cx) + }); + + _ = editor.update(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(7), 0)..DisplayPoint::new(DisplayRow(12), 0) + ]); + }); + editor.fold(&Fold, window, cx); + assert_eq!( + editor.display_text(cx), + " + impl Foo { + // Hello! + + fn a() { + 1 + } + + fn b() {⋯ + } + + fn c() {⋯ + } + } + " + .unindent(), + ); + + editor.fold(&Fold, window, cx); + assert_eq!( + editor.display_text(cx), + " + impl Foo {⋯ + } + " + .unindent(), + ); + + editor.unfold_lines(&UnfoldLines, window, cx); + assert_eq!( + editor.display_text(cx), + " + impl Foo { + // Hello! + + fn a() { + 1 + } + + fn b() {⋯ + } + + fn c() {⋯ + } + } + " + .unindent(), + ); + + editor.unfold_lines(&UnfoldLines, window, cx); + assert_eq!( + editor.display_text(cx), + editor.buffer.read(cx).read(cx).text() + ); + }); +} + +#[gpui::test] +fn test_fold_action_whitespace_sensitive_language(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let editor = cx.add_window(|window, cx| { + let buffer = MultiBuffer::build_simple( + &" + class Foo: + # Hello! + + def a(): + print(1) + + def b(): + print(2) + + def c(): + print(3) + " + .unindent(), + cx, + ); + build_editor(buffer, window, cx) + }); + + _ = editor.update(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(6), 0)..DisplayPoint::new(DisplayRow(10), 0) + ]); + }); + editor.fold(&Fold, window, cx); + assert_eq!( + editor.display_text(cx), + " + class Foo: + # Hello! + + def a(): + print(1) + + def b():⋯ + + def c():⋯ + " + .unindent(), + ); + + editor.fold(&Fold, window, cx); + assert_eq!( + editor.display_text(cx), + " + class Foo:⋯ + " + .unindent(), + ); + + editor.unfold_lines(&UnfoldLines, window, cx); + assert_eq!( + editor.display_text(cx), + " + class Foo: + # Hello! + + def a(): + print(1) + + def b():⋯ + + def c():⋯ + " + .unindent(), + ); + + editor.unfold_lines(&UnfoldLines, window, cx); + assert_eq!( + editor.display_text(cx), + editor.buffer.read(cx).read(cx).text() + ); + }); +} + +#[gpui::test] +fn test_fold_action_multiple_line_breaks(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let editor = cx.add_window(|window, cx| { + let buffer = MultiBuffer::build_simple( + &" + class Foo: + # Hello! + + def a(): + print(1) + + def b(): + print(2) + + + def c(): + print(3) + + + " + .unindent(), + cx, + ); + build_editor(buffer, window, cx) + }); + + _ = editor.update(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(6), 0)..DisplayPoint::new(DisplayRow(11), 0) + ]); + }); + editor.fold(&Fold, window, cx); + assert_eq!( + editor.display_text(cx), + " + class Foo: + # Hello! + + def a(): + print(1) + + def b():⋯ + + + def c():⋯ + + + " + .unindent(), + ); + + editor.fold(&Fold, window, cx); + assert_eq!( + editor.display_text(cx), + " + class Foo:⋯ + + + " + .unindent(), + ); + + editor.unfold_lines(&UnfoldLines, window, cx); + assert_eq!( + editor.display_text(cx), + " + class Foo: + # Hello! + + def a(): + print(1) + + def b():⋯ + + + def c():⋯ + + + " + .unindent(), + ); + + editor.unfold_lines(&UnfoldLines, window, cx); + assert_eq!( + editor.display_text(cx), + editor.buffer.read(cx).read(cx).text() + ); + }); +} + +#[gpui::test] +async fn test_fold_with_unindented_multiline_raw_string(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + let language = Arc::new( + Language::new( + LanguageConfig::default(), + Some(tree_sitter_rust::LANGUAGE.into()), + ) + .with_queries(LanguageQueries { + overrides: Some(Cow::from(indoc! {" + [ + (string_literal) + (raw_string_literal) + ] @string + [ + (line_comment) + (block_comment) + ] @comment.inclusive + "})), + ..Default::default() + }) + .expect("Could not parse queries"), + ); + + cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); + cx.set_state(indoc! {" + fn main() { + let s = r#\" + a + b + c + \"#; + }ˇ + "}); + + cx.update_editor(|editor, window, cx| { + editor.fold_at_level(&FoldAtLevel(1), window, cx); + assert_eq!( + editor.display_text(cx), + indoc! {" + fn main() {⋯ + } + "}, + ); + }); +} + +#[gpui::test] +async fn test_fold_with_unindented_multiline_raw_string_includes_closing_bracket( + cx: &mut TestAppContext, +) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + cx.update_buffer(|buffer, cx| buffer.set_language(Some(rust_lang()), cx)); + cx.set_state(indoc! {" + ˇfn main() { + let s = r#\" + a + b + c + \"#; + } + "}); + + cx.update_editor(|editor, window, cx| { + editor.fold_at_level(&FoldAtLevel(1), window, cx); + assert_eq!( + editor.display_text(cx), + indoc! {" + fn main() {⋯} + "}, + ); + }); +} + +#[gpui::test] +async fn test_fold_with_unindented_multiline_block_comment(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + let language = Arc::new( + Language::new( + LanguageConfig::default(), + Some(tree_sitter_rust::LANGUAGE.into()), + ) + .with_queries(LanguageQueries { + overrides: Some(Cow::from(indoc! {" + [ + (string_literal) + (raw_string_literal) + ] @string + [ + (line_comment) + (block_comment) + ] @comment.inclusive + "})), + ..Default::default() + }) + .expect("Could not parse queries"), + ); + + cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); + cx.set_state(indoc! {" + fn main() { + let x = 1; + /* + unindented comment line + */ + }ˇ + "}); + + cx.update_editor(|editor, window, cx| { + editor.fold_at_level(&FoldAtLevel(1), window, cx); + assert_eq!( + editor.display_text(cx), + indoc! {" + fn main() {⋯ + } + "}, + ); + }); +} + +#[gpui::test] +async fn test_fold_with_unindented_multiline_block_comment_includes_closing_bracket( + cx: &mut TestAppContext, +) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + cx.update_buffer(|buffer, cx| buffer.set_language(Some(rust_lang()), cx)); + cx.set_state(indoc! {" + ˇfn main() { + let x = 1; + /* + unindented comment line + */ + } + "}); + + cx.update_editor(|editor, window, cx| { + editor.fold_at_level(&FoldAtLevel(1), window, cx); + assert_eq!( + editor.display_text(cx), + indoc! {" + fn main() {⋯} + "}, + ); + }); +} + +#[gpui::test] +async fn test_fold_preserves_top_level_comments_between_python_classes(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + let language = Arc::new( + Language::new( + LanguageConfig::default(), + Some(tree_sitter_python::LANGUAGE.into()), + ) + .with_queries(LanguageQueries { + overrides: Some(Cow::from(indoc! {" + (comment) @comment.inclusive + (string) @string + "})), + ..Default::default() + }) + .expect("Could not parse queries"), + ); + + cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); + cx.set_state(indoc! {" + class Foo: + def bar(self): + pass + + + # SECTION SEPARATOR + + class Baz: + def qux(self): + passˇ + "}); + + cx.update_editor(|editor, window, cx| { + editor.fold_at_level(&FoldAtLevel(1), window, cx); + assert_eq!( + editor.display_text(cx), + indoc! {" + class Foo:⋯ + + + # SECTION SEPARATOR + + class Baz: + def qux(self): + pass + "}, + ); + }); +} + +#[gpui::test] +async fn test_fold_preserves_top_level_comments_between_rust_functions(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + let language = Arc::new( + Language::new( + LanguageConfig::default(), + Some(tree_sitter_rust::LANGUAGE.into()), + ) + .with_queries(LanguageQueries { + overrides: Some(Cow::from(indoc! {" + [ + (string_literal) + (raw_string_literal) + ] @string + [ + (line_comment) + (block_comment) + ] @comment.inclusive + "})), + ..Default::default() + }) + .expect("Could not parse queries"), + ); + + cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); + cx.set_state(indoc! {" + fn foo() { + bar(); + } + + + // SECTION SEPARATOR + + + fn baz() { + qux();ˇ + } + "}); + + cx.update_editor(|editor, window, cx| { + editor.fold_at_level(&FoldAtLevel(1), window, cx); + assert_eq!( + editor.display_text(cx), + indoc! {" + fn foo() {⋯ + } + + + // SECTION SEPARATOR + + + fn baz() { + qux(); + } + "}, + ); + }); +} + +#[gpui::test] +async fn test_fold_terminates_at_top_level_multiline_string_between_python_classes( + cx: &mut TestAppContext, +) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + let language = Arc::new( + Language::new( + LanguageConfig::default(), + Some(tree_sitter_python::LANGUAGE.into()), + ) + .with_queries(LanguageQueries { + overrides: Some(Cow::from(indoc! {" + (comment) @comment.inclusive + (string) @string + "})), + ..Default::default() + }) + .expect("Could not parse queries"), + ); + + cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); + cx.set_state(indoc! {r#" + class Foo: + def bar(self): + pass + + + """ + top-level docstring at zero indent + """ + + + class Baz: + def qux(self): + passˇ + "#}); + + cx.update_editor(|editor, window, cx| { + editor.fold_at_level(&FoldAtLevel(1), window, cx); + assert_eq!( + editor.display_text(cx), + indoc! {r#" + class Foo:⋯ + + + """ + top-level docstring at zero indent + """ + + + class Baz: + def qux(self): + pass + "#}, + ); + }); +} + +#[gpui::test] +fn test_fold_at_level(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let editor = cx.add_window(|window, cx| { + let buffer = MultiBuffer::build_simple( + &" + class Foo: + # Hello! + + def a(): + print(1) + + def b(): + print(2) + + + class Bar: + # World! + + def a(): + print(1) + + def b(): + print(2) + + + " + .unindent(), + cx, + ); + build_editor(buffer, window, cx) + }); + + _ = editor.update(cx, |editor, window, cx| { + editor.fold_at_level(&FoldAtLevel(2), window, cx); + assert_eq!( + editor.display_text(cx), + " + class Foo: + # Hello! + + def a():⋯ + + def b():⋯ + + + class Bar: + # World! + + def a():⋯ + + def b():⋯ + + + " + .unindent(), + ); + + editor.fold_at_level(&FoldAtLevel(1), window, cx); + assert_eq!( + editor.display_text(cx), + " + class Foo:⋯ + + + class Bar:⋯ + + + " + .unindent(), + ); + + editor.unfold_all(&UnfoldAll, window, cx); + editor.fold_at_level(&FoldAtLevel(0), window, cx); + assert_eq!( + editor.display_text(cx), + " + class Foo: + # Hello! + + def a(): + print(1) + + def b(): + print(2) + + + class Bar: + # World! + + def a(): + print(1) + + def b(): + print(2) + + + " + .unindent(), + ); + + assert_eq!( + editor.display_text(cx), + editor.buffer.read(cx).read(cx).text() + ); + let (_, positions) = marked_text_ranges( + &" + class Foo: + # Hello! + + def a(): + print(1) + + def b(): + p«riˇ»nt(2) + + + class Bar: + # World! + + def a(): + «ˇprint(1) + + def b(): + print(2)» + + + " + .unindent(), + true, + ); + + editor.change_selections(SelectionEffects::default(), window, cx, |s| { + s.select_ranges( + positions + .iter() + .map(|range| MultiBufferOffset(range.start)..MultiBufferOffset(range.end)), + ) + }); + + editor.fold_at_level(&FoldAtLevel(2), window, cx); + assert_eq!( + editor.display_text(cx), + " + class Foo: + # Hello! + + def a():⋯ + + def b(): + print(2) + + + class Bar: + # World! + + def a(): + print(1) + + def b(): + print(2) + + + " + .unindent(), + ); + }); +} + +#[gpui::test] +fn test_fold_at_level_chain_fold(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let editor = cx.add_window(|window, cx| { + let text = " + fn foo( + a: i32, + ) { + if true { + let b = a; + } else { + let b = a; + } + } + " + .unindent(); + let buffer = cx.new(|cx| Buffer::local(&text, cx).with_language(rust_lang(), cx)); + let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + + build_editor(buffer, window, cx) + }); + + editor + .update(cx, |editor, window, cx| { + editor.fold_at_level(&FoldAtLevel(1), window, cx); + assert_eq!( + editor.display_text(cx), + " + fn foo(⋯) {⋯} + " + .unindent(), + ); + + editor.unfold_all(&UnfoldAll, window, cx); + editor.fold_at_level(&FoldAtLevel(2), window, cx); + assert_eq!( + editor.display_text(cx), + " + fn foo( + a: i32, + ) { + if true {⋯} else {⋯} + } + " + .unindent(), + ); + }) + .unwrap(); +} + +#[gpui::test] +fn test_fold_at_level_with_single_row_crease(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let editor = cx.add_window(|window, cx| { + let buffer = MultiBuffer::build_simple("aaaaaa\n@file.txt and more\ncccccc\n", cx); + build_editor(buffer, window, cx) + }); + + editor + .update(cx, |editor, window, cx| { + let snapshot = editor.buffer().read(cx).snapshot(cx); + let range = + snapshot.anchor_before(Point::new(1, 0))..snapshot.anchor_after(Point::new(1, 9)); + + editor.insert_creases(Some(Crease::simple(range, FoldPlaceholder::test())), cx); + + editor.fold_at_level(&FoldAtLevel(1), window, cx); + + assert_eq!( + editor.display_text(cx), + " + aaaaaa + ⋯ and more + cccccc + " + .unindent(), + ); + }) + .unwrap(); +} + +#[gpui::test] +fn test_fold_at_level_with_crease_on_boundary_row(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let editor = cx.add_window(|window, cx| { + let buffer = MultiBuffer::build_simple("aaa\nbbb\nccc\nddd eee\nfff\n", cx); + build_editor(buffer, window, cx) + }); + + editor + .update(cx, |editor, window, cx| { + let snapshot = editor.buffer().read(cx).snapshot(cx); + let outer = + snapshot.anchor_before(Point::new(1, 0))..snapshot.anchor_after(Point::new(3, 7)); + let inner = + snapshot.anchor_before(Point::new(3, 0))..snapshot.anchor_after(Point::new(3, 3)); + + editor.insert_creases( + [ + Crease::simple(outer, FoldPlaceholder::test()), + Crease::simple(inner, FoldPlaceholder::test()), + ], + cx, + ); + + editor.fold_at_level(&FoldAtLevel(1), window, cx); + assert_eq!(editor.display_text(cx), "aaa\n⋯\nfff\n"); + + editor.unfold_all(&UnfoldAll, window, cx); + editor.fold_at_level(&FoldAtLevel(2), window, cx); + assert_eq!(editor.display_text(cx), "aaa\nbbb\nccc\nddd eee\nfff\n"); + }) + .unwrap(); +} + +#[gpui::test] +fn test_move_cursor(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let buffer = cx.update(|cx| MultiBuffer::build_simple(&sample_text(6, 6, 'a'), cx)); + let editor = cx.add_window(|window, cx| build_editor(buffer.clone(), window, cx)); + + buffer.update(cx, |buffer, cx| { + buffer.edit( + vec![ + (Point::new(1, 0)..Point::new(1, 0), "\t"), + (Point::new(1, 1)..Point::new(1, 1), "\t"), + ], + None, + cx, + ); + }); + _ = editor.update(cx, |editor, window, cx| { + assert_eq!( + display_ranges(editor, cx), + &[DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 0)] + ); + + editor.move_down(&MoveDown, window, cx); + assert_eq!( + display_ranges(editor, cx), + &[DisplayPoint::new(DisplayRow(1), 0)..DisplayPoint::new(DisplayRow(1), 0)] + ); + + editor.move_right(&MoveRight, window, cx); + assert_eq!( + display_ranges(editor, cx), + &[DisplayPoint::new(DisplayRow(1), 4)..DisplayPoint::new(DisplayRow(1), 4)] + ); + + editor.move_left(&MoveLeft, window, cx); + assert_eq!( + display_ranges(editor, cx), + &[DisplayPoint::new(DisplayRow(1), 0)..DisplayPoint::new(DisplayRow(1), 0)] + ); + + editor.move_up(&MoveUp, window, cx); + assert_eq!( + display_ranges(editor, cx), + &[DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 0)] + ); + + editor.move_to_end(&MoveToEnd, window, cx); + assert_eq!( + display_ranges(editor, cx), + &[DisplayPoint::new(DisplayRow(5), 6)..DisplayPoint::new(DisplayRow(5), 6)] + ); + + editor.move_to_beginning(&MoveToBeginning, window, cx); + assert_eq!( + display_ranges(editor, cx), + &[DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 0)] + ); + + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(0), 1)..DisplayPoint::new(DisplayRow(0), 2) + ]); + }); + editor.select_to_beginning(&SelectToBeginning, window, cx); + assert_eq!( + display_ranges(editor, cx), + &[DisplayPoint::new(DisplayRow(0), 1)..DisplayPoint::new(DisplayRow(0), 0)] + ); + + editor.select_to_end(&SelectToEnd, window, cx); + assert_eq!( + display_ranges(editor, cx), + &[DisplayPoint::new(DisplayRow(0), 1)..DisplayPoint::new(DisplayRow(5), 6)] + ); + }); +} + +#[gpui::test] +fn test_move_cursor_multibyte(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let editor = cx.add_window(|window, cx| { + let buffer = MultiBuffer::build_simple("🟥🟧🟨🟩🟦🟪\nabcde\nαβγδε", cx); + build_editor(buffer, window, cx) + }); + + assert_eq!('🟥'.len_utf8(), 4); + assert_eq!('α'.len_utf8(), 2); + + _ = editor.update(cx, |editor, window, cx| { + editor.fold_creases( + vec![ + Crease::simple(Point::new(0, 8)..Point::new(0, 16), FoldPlaceholder::test()), + Crease::simple(Point::new(1, 2)..Point::new(1, 4), FoldPlaceholder::test()), + Crease::simple(Point::new(2, 4)..Point::new(2, 8), FoldPlaceholder::test()), + ], + true, + window, + cx, + ); + assert_eq!(editor.display_text(cx), "🟥🟧⋯🟦🟪\nab⋯e\nαβ⋯ε"); + + editor.move_right(&MoveRight, window, cx); + assert_eq!(display_ranges(editor, cx), &[empty_range(0, "🟥".len())]); + editor.move_right(&MoveRight, window, cx); + assert_eq!(display_ranges(editor, cx), &[empty_range(0, "🟥🟧".len())]); + editor.move_right(&MoveRight, window, cx); + assert_eq!(display_ranges(editor, cx), &[empty_range(0, "🟥🟧⋯".len())]); + + editor.move_down(&MoveDown, window, cx); + assert_eq!(display_ranges(editor, cx), &[empty_range(1, "ab⋯e".len())]); + editor.move_left(&MoveLeft, window, cx); + assert_eq!(display_ranges(editor, cx), &[empty_range(1, "ab⋯".len())]); + editor.move_left(&MoveLeft, window, cx); + assert_eq!(display_ranges(editor, cx), &[empty_range(1, "ab".len())]); + editor.move_left(&MoveLeft, window, cx); + assert_eq!(display_ranges(editor, cx), &[empty_range(1, "a".len())]); + + editor.move_down(&MoveDown, window, cx); + assert_eq!(display_ranges(editor, cx), &[empty_range(2, "α".len())]); + editor.move_right(&MoveRight, window, cx); + assert_eq!(display_ranges(editor, cx), &[empty_range(2, "αβ".len())]); + editor.move_right(&MoveRight, window, cx); + assert_eq!(display_ranges(editor, cx), &[empty_range(2, "αβ⋯".len())]); + editor.move_right(&MoveRight, window, cx); + assert_eq!(display_ranges(editor, cx), &[empty_range(2, "αβ⋯ε".len())]); + + editor.move_up(&MoveUp, window, cx); + assert_eq!(display_ranges(editor, cx), &[empty_range(1, "ab⋯e".len())]); + editor.move_down(&MoveDown, window, cx); + assert_eq!(display_ranges(editor, cx), &[empty_range(2, "αβ⋯ε".len())]); + editor.move_up(&MoveUp, window, cx); + assert_eq!(display_ranges(editor, cx), &[empty_range(1, "ab⋯e".len())]); + + editor.move_up(&MoveUp, window, cx); + assert_eq!(display_ranges(editor, cx), &[empty_range(0, "🟥🟧".len())]); + editor.move_left(&MoveLeft, window, cx); + assert_eq!(display_ranges(editor, cx), &[empty_range(0, "🟥".len())]); + editor.move_left(&MoveLeft, window, cx); + assert_eq!(display_ranges(editor, cx), &[empty_range(0, "".len())]); + }); +} + +#[gpui::test] +fn test_move_cursor_different_line_lengths(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let editor = cx.add_window(|window, cx| { + let buffer = MultiBuffer::build_simple("ⓐⓑⓒⓓⓔ\nabcd\nαβγ\nabcd\nⓐⓑⓒⓓⓔ\n", cx); + build_editor(buffer, window, cx) + }); + _ = editor.update(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([empty_range(0, "ⓐⓑⓒⓓⓔ".len())]); + }); + + // moving above start of document should move selection to start of document, + // but the next move down should still be at the original goal_x + editor.move_up(&MoveUp, window, cx); + assert_eq!(display_ranges(editor, cx), &[empty_range(0, "".len())]); + + editor.move_down(&MoveDown, window, cx); + assert_eq!(display_ranges(editor, cx), &[empty_range(1, "abcd".len())]); + + editor.move_down(&MoveDown, window, cx); + assert_eq!(display_ranges(editor, cx), &[empty_range(2, "αβγ".len())]); + + editor.move_down(&MoveDown, window, cx); + assert_eq!(display_ranges(editor, cx), &[empty_range(3, "abcd".len())]); + + editor.move_down(&MoveDown, window, cx); + assert_eq!(display_ranges(editor, cx), &[empty_range(4, "ⓐⓑⓒⓓⓔ".len())]); + + // moving past end of document should not change goal_x + editor.move_down(&MoveDown, window, cx); + assert_eq!(display_ranges(editor, cx), &[empty_range(5, "".len())]); + + editor.move_down(&MoveDown, window, cx); + assert_eq!(display_ranges(editor, cx), &[empty_range(5, "".len())]); + + editor.move_up(&MoveUp, window, cx); + assert_eq!(display_ranges(editor, cx), &[empty_range(4, "ⓐⓑⓒⓓⓔ".len())]); + + editor.move_up(&MoveUp, window, cx); + assert_eq!(display_ranges(editor, cx), &[empty_range(3, "abcd".len())]); + + editor.move_up(&MoveUp, window, cx); + assert_eq!(display_ranges(editor, cx), &[empty_range(2, "αβγ".len())]); + }); +} + +#[gpui::test] +fn test_beginning_end_of_line(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let move_to_beg = MoveToBeginningOfLine { + stop_at_soft_wraps: true, + stop_at_indent: true, + }; + + let delete_to_beg = DeleteToBeginningOfLine { + stop_at_indent: false, + }; + + let move_to_end = MoveToEndOfLine { + stop_at_soft_wraps: true, + }; + + let editor = cx.add_window(|window, cx| { + let buffer = MultiBuffer::build_simple("abc\n def", cx); + build_editor(buffer, window, cx) + }); + _ = editor.update(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(0), 1)..DisplayPoint::new(DisplayRow(0), 1), + DisplayPoint::new(DisplayRow(1), 4)..DisplayPoint::new(DisplayRow(1), 4), + ]); + }); + }); + + _ = editor.update(cx, |editor, window, cx| { + editor.move_to_beginning_of_line(&move_to_beg, window, cx); + assert_eq!( + display_ranges(editor, cx), + &[ + DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 0), + DisplayPoint::new(DisplayRow(1), 2)..DisplayPoint::new(DisplayRow(1), 2), + ] + ); + }); + + _ = editor.update(cx, |editor, window, cx| { + editor.move_to_beginning_of_line(&move_to_beg, window, cx); + assert_eq!( + display_ranges(editor, cx), + &[ + DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 0), + DisplayPoint::new(DisplayRow(1), 0)..DisplayPoint::new(DisplayRow(1), 0), + ] + ); + }); + + _ = editor.update(cx, |editor, window, cx| { + editor.move_to_beginning_of_line(&move_to_beg, window, cx); + assert_eq!( + display_ranges(editor, cx), + &[ + DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 0), + DisplayPoint::new(DisplayRow(1), 2)..DisplayPoint::new(DisplayRow(1), 2), + ] + ); + }); + + _ = editor.update(cx, |editor, window, cx| { + editor.move_to_end_of_line(&move_to_end, window, cx); + assert_eq!( + display_ranges(editor, cx), + &[ + DisplayPoint::new(DisplayRow(0), 3)..DisplayPoint::new(DisplayRow(0), 3), + DisplayPoint::new(DisplayRow(1), 5)..DisplayPoint::new(DisplayRow(1), 5), + ] + ); + }); + + // Moving to the end of line again is a no-op. + _ = editor.update(cx, |editor, window, cx| { + editor.move_to_end_of_line(&move_to_end, window, cx); + assert_eq!( + display_ranges(editor, cx), + &[ + DisplayPoint::new(DisplayRow(0), 3)..DisplayPoint::new(DisplayRow(0), 3), + DisplayPoint::new(DisplayRow(1), 5)..DisplayPoint::new(DisplayRow(1), 5), + ] + ); + }); + + _ = editor.update(cx, |editor, window, cx| { + editor.move_left(&MoveLeft, window, cx); + editor.select_to_beginning_of_line( + &SelectToBeginningOfLine { + stop_at_soft_wraps: true, + stop_at_indent: true, + }, + window, + cx, + ); + assert_eq!( + display_ranges(editor, cx), + &[ + DisplayPoint::new(DisplayRow(0), 2)..DisplayPoint::new(DisplayRow(0), 0), + DisplayPoint::new(DisplayRow(1), 4)..DisplayPoint::new(DisplayRow(1), 2), + ] + ); + }); + + _ = editor.update(cx, |editor, window, cx| { + editor.select_to_beginning_of_line( + &SelectToBeginningOfLine { + stop_at_soft_wraps: true, + stop_at_indent: true, + }, + window, + cx, + ); + assert_eq!( + display_ranges(editor, cx), + &[ + DisplayPoint::new(DisplayRow(0), 2)..DisplayPoint::new(DisplayRow(0), 0), + DisplayPoint::new(DisplayRow(1), 4)..DisplayPoint::new(DisplayRow(1), 0), + ] + ); + }); + + _ = editor.update(cx, |editor, window, cx| { + editor.select_to_beginning_of_line( + &SelectToBeginningOfLine { + stop_at_soft_wraps: true, + stop_at_indent: true, + }, + window, + cx, + ); + assert_eq!( + display_ranges(editor, cx), + &[ + DisplayPoint::new(DisplayRow(0), 2)..DisplayPoint::new(DisplayRow(0), 0), + DisplayPoint::new(DisplayRow(1), 4)..DisplayPoint::new(DisplayRow(1), 2), + ] + ); + }); + + _ = editor.update(cx, |editor, window, cx| { + editor.select_to_end_of_line( + &SelectToEndOfLine { + stop_at_soft_wraps: true, + }, + window, + cx, + ); + assert_eq!( + display_ranges(editor, cx), + &[ + DisplayPoint::new(DisplayRow(0), 2)..DisplayPoint::new(DisplayRow(0), 3), + DisplayPoint::new(DisplayRow(1), 4)..DisplayPoint::new(DisplayRow(1), 5), + ] + ); + }); + + _ = editor.update(cx, |editor, window, cx| { + editor.delete_to_end_of_line(&DeleteToEndOfLine, window, cx); + assert_eq!(editor.display_text(cx), "ab\n de"); + assert_eq!( + display_ranges(editor, cx), + &[ + DisplayPoint::new(DisplayRow(0), 2)..DisplayPoint::new(DisplayRow(0), 2), + DisplayPoint::new(DisplayRow(1), 4)..DisplayPoint::new(DisplayRow(1), 4), + ] + ); + }); + + _ = editor.update(cx, |editor, window, cx| { + editor.delete_to_beginning_of_line(&delete_to_beg, window, cx); + assert_eq!(editor.display_text(cx), "\n"); + assert_eq!( + display_ranges(editor, cx), + &[ + DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 0), + DisplayPoint::new(DisplayRow(1), 0)..DisplayPoint::new(DisplayRow(1), 0), + ] + ); + }); +} + +#[gpui::test] +fn test_beginning_of_line_single_line_editor(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let editor = cx.add_window(|window, cx| Editor::single_line(window, cx)); + + _ = editor.update(cx, |editor, window, cx| { + editor.set_text(" indented text", window, cx); + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(0), 10)..DisplayPoint::new(DisplayRow(0), 10) + ]); + }); + + editor.move_to_beginning_of_line( + &MoveToBeginningOfLine { + stop_at_soft_wraps: true, + stop_at_indent: true, + }, + window, + cx, + ); + assert_eq!( + display_ranges(editor, cx), + &[DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 0)] + ); + }); + + _ = editor.update(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(0), 10)..DisplayPoint::new(DisplayRow(0), 10) + ]); + }); + + editor.select_to_beginning_of_line( + &SelectToBeginningOfLine { + stop_at_soft_wraps: true, + stop_at_indent: true, + }, + window, + cx, + ); + assert_eq!( + display_ranges(editor, cx), + &[DisplayPoint::new(DisplayRow(0), 10)..DisplayPoint::new(DisplayRow(0), 0)] + ); + }); +} + +#[gpui::test] +fn test_only_focused_editor_blinks_across_window_activation(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let window = cx.add_window(|window, cx| Editor::single_line(window, cx)); + let unfocused_editor = window + .update(cx, |focused_editor, window, cx| { + window.focus(&focused_editor.focus_handle(cx), cx); + let unfocused_editor = cx.new(|cx| Editor::single_line(window, cx)); + window.activate_window(); + unfocused_editor + }) + .unwrap(); + let focused_editor = window.root(cx).unwrap(); + let cx = &mut VisualTestContext::from_window(*window, cx); + cx.run_until_parked(); + + cx.update(|window, cx| { + assert!(window.is_window_active()); + assert!(focused_editor.read(cx).blink_manager.read(cx).enabled()); + assert!(!unfocused_editor.read(cx).blink_manager.read(cx).enabled()); + }); + + cx.deactivate_window(); + cx.update(|window, cx| { + assert!(!window.is_window_active()); + assert!(!focused_editor.read(cx).blink_manager.read(cx).enabled()); + assert!(!unfocused_editor.read(cx).blink_manager.read(cx).enabled()); + }); + + cx.update(|window, _| window.activate_window()); + cx.run_until_parked(); + cx.update(|window, cx| { + assert!(window.is_window_active()); + assert!(focused_editor.read(cx).blink_manager.read(cx).enabled()); + assert!(!unfocused_editor.read(cx).blink_manager.read(cx).enabled()); + }); +} + +#[gpui::test] +fn test_beginning_end_of_line_ignore_soft_wrap(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let move_to_beg = MoveToBeginningOfLine { + stop_at_soft_wraps: false, + stop_at_indent: false, + }; + + let move_to_end = MoveToEndOfLine { + stop_at_soft_wraps: false, + }; + + let editor = cx.add_window(|window, cx| { + let buffer = MultiBuffer::build_simple("thequickbrownfox\njumpedoverthelazydogs", cx); + build_editor(buffer, window, cx) + }); + + _ = editor.update(cx, |editor, window, cx| { + editor.set_wrap_width(Some(140.0.into()), cx); + + // We expect the following lines after wrapping + // ``` + // thequickbrownfox + // jumpedoverthelazydo + // gs + // ``` + // The final `gs` was soft-wrapped onto a new line. + assert_eq!( + "thequickbrownfox\njumpedoverthelaz\nydogs", + editor.display_text(cx), + ); + + // First, let's assert behavior on the first line, that was not soft-wrapped. + // Start the cursor at the `k` on the first line + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(0), 7)..DisplayPoint::new(DisplayRow(0), 7) + ]); + }); + + // Moving to the beginning of the line should put us at the beginning of the line. + editor.move_to_beginning_of_line(&move_to_beg, window, cx); + assert_eq!( + vec![DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 0),], + display_ranges(editor, cx) + ); + + // Moving to the end of the line should put us at the end of the line. + editor.move_to_end_of_line(&move_to_end, window, cx); + assert_eq!( + vec![DisplayPoint::new(DisplayRow(0), 16)..DisplayPoint::new(DisplayRow(0), 16),], + display_ranges(editor, cx) + ); + + // Now, let's assert behavior on the second line, that ended up being soft-wrapped. + // Start the cursor at the last line (`y` that was wrapped to a new line) + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(2), 0)..DisplayPoint::new(DisplayRow(2), 0) + ]); + }); + + // Moving to the beginning of the line should put us at the start of the second line of + // display text, i.e., the `j`. + editor.move_to_beginning_of_line(&move_to_beg, window, cx); + assert_eq!( + vec![DisplayPoint::new(DisplayRow(1), 0)..DisplayPoint::new(DisplayRow(1), 0),], + display_ranges(editor, cx) + ); + + // Moving to the beginning of the line again should be a no-op. + editor.move_to_beginning_of_line(&move_to_beg, window, cx); + assert_eq!( + vec![DisplayPoint::new(DisplayRow(1), 0)..DisplayPoint::new(DisplayRow(1), 0),], + display_ranges(editor, cx) + ); + + // Moving to the end of the line should put us right after the `s` that was soft-wrapped to the + // next display line. + editor.move_to_end_of_line(&move_to_end, window, cx); + assert_eq!( + vec![DisplayPoint::new(DisplayRow(2), 5)..DisplayPoint::new(DisplayRow(2), 5),], + display_ranges(editor, cx) + ); + + // Moving to the end of the line again should be a no-op. + editor.move_to_end_of_line(&move_to_end, window, cx); + assert_eq!( + vec![DisplayPoint::new(DisplayRow(2), 5)..DisplayPoint::new(DisplayRow(2), 5),], + display_ranges(editor, cx) + ); + }); +} + +#[gpui::test] +fn test_beginning_of_line_stop_at_indent(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let move_to_beg = MoveToBeginningOfLine { + stop_at_soft_wraps: true, + stop_at_indent: true, + }; + + let select_to_beg = SelectToBeginningOfLine { + stop_at_soft_wraps: true, + stop_at_indent: true, + }; + + let delete_to_beg = DeleteToBeginningOfLine { + stop_at_indent: true, + }; + + let move_to_end = MoveToEndOfLine { + stop_at_soft_wraps: false, + }; + + let editor = cx.add_window(|window, cx| { + let buffer = MultiBuffer::build_simple("abc\n def", cx); + build_editor(buffer, window, cx) + }); + + _ = editor.update(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(0), 1)..DisplayPoint::new(DisplayRow(0), 1), + DisplayPoint::new(DisplayRow(1), 4)..DisplayPoint::new(DisplayRow(1), 4), + ]); + }); + + // Moving to the beginning of the line should put the first cursor at the beginning of the line, + // and the second cursor at the first non-whitespace character in the line. + editor.move_to_beginning_of_line(&move_to_beg, window, cx); + assert_eq!( + display_ranges(editor, cx), + &[ + DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 0), + DisplayPoint::new(DisplayRow(1), 2)..DisplayPoint::new(DisplayRow(1), 2), + ] + ); + + // Moving to the beginning of the line again should be a no-op for the first cursor, + // and should move the second cursor to the beginning of the line. + editor.move_to_beginning_of_line(&move_to_beg, window, cx); + assert_eq!( + display_ranges(editor, cx), + &[ + DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 0), + DisplayPoint::new(DisplayRow(1), 0)..DisplayPoint::new(DisplayRow(1), 0), + ] + ); + + // Moving to the beginning of the line again should still be a no-op for the first cursor, + // and should move the second cursor back to the first non-whitespace character in the line. + editor.move_to_beginning_of_line(&move_to_beg, window, cx); + assert_eq!( + display_ranges(editor, cx), + &[ + DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 0), + DisplayPoint::new(DisplayRow(1), 2)..DisplayPoint::new(DisplayRow(1), 2), + ] + ); + + // Selecting to the beginning of the line should select to the beginning of the line for the first cursor, + // and to the first non-whitespace character in the line for the second cursor. + editor.move_to_end_of_line(&move_to_end, window, cx); + editor.move_left(&MoveLeft, window, cx); + editor.select_to_beginning_of_line(&select_to_beg, window, cx); + assert_eq!( + display_ranges(editor, cx), + &[ + DisplayPoint::new(DisplayRow(0), 2)..DisplayPoint::new(DisplayRow(0), 0), + DisplayPoint::new(DisplayRow(1), 4)..DisplayPoint::new(DisplayRow(1), 2), + ] + ); + + // Selecting to the beginning of the line again should be a no-op for the first cursor, + // and should select to the beginning of the line for the second cursor. + editor.select_to_beginning_of_line(&select_to_beg, window, cx); + assert_eq!( + display_ranges(editor, cx), + &[ + DisplayPoint::new(DisplayRow(0), 2)..DisplayPoint::new(DisplayRow(0), 0), + DisplayPoint::new(DisplayRow(1), 4)..DisplayPoint::new(DisplayRow(1), 0), + ] + ); + + // Deleting to the beginning of the line should delete to the beginning of the line for the first cursor, + // and should delete to the first non-whitespace character in the line for the second cursor. + editor.move_to_end_of_line(&move_to_end, window, cx); + editor.move_left(&MoveLeft, window, cx); + editor.delete_to_beginning_of_line(&delete_to_beg, window, cx); + assert_eq!(editor.text(cx), "c\n f"); + }); +} + +#[gpui::test] +fn test_beginning_of_line_with_cursor_between_line_start_and_indent(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let move_to_beg = MoveToBeginningOfLine { + stop_at_soft_wraps: true, + stop_at_indent: true, + }; + + let editor = cx.add_window(|window, cx| { + let buffer = MultiBuffer::build_simple(" hello\nworld", cx); + build_editor(buffer, window, cx) + }); + + _ = editor.update(cx, |editor, window, cx| { + // test cursor between line_start and indent_start + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(0), 3)..DisplayPoint::new(DisplayRow(0), 3) + ]); + }); + + // cursor should move to line_start + editor.move_to_beginning_of_line(&move_to_beg, window, cx); + assert_eq!( + display_ranges(editor, cx), + &[DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 0)] + ); + + // cursor should move to indent_start + editor.move_to_beginning_of_line(&move_to_beg, window, cx); + assert_eq!( + display_ranges(editor, cx), + &[DisplayPoint::new(DisplayRow(0), 4)..DisplayPoint::new(DisplayRow(0), 4)] + ); + + // cursor should move to back to line_start + editor.move_to_beginning_of_line(&move_to_beg, window, cx); + assert_eq!( + display_ranges(editor, cx), + &[DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 0)] + ); + }); +} + +#[gpui::test] +fn test_prev_next_word_boundary(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let editor = cx.add_window(|window, cx| { + let buffer = MultiBuffer::build_simple("use std::str::{foo, bar}\n\n {baz.qux()}", cx); + build_editor(buffer, window, cx) + }); + _ = editor.update(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(0), 11)..DisplayPoint::new(DisplayRow(0), 11), + DisplayPoint::new(DisplayRow(2), 4)..DisplayPoint::new(DisplayRow(2), 4), + ]) + }); + editor.move_to_previous_word_start(&MoveToPreviousWordStart, window, cx); + assert_selection_ranges("use std::ˇstr::{foo, bar}\n\n {ˇbaz.qux()}", editor, cx); + + editor.move_to_previous_word_start(&MoveToPreviousWordStart, window, cx); + assert_selection_ranges("use stdˇ::str::{foo, bar}\n\n ˇ{baz.qux()}", editor, cx); + + editor.move_to_previous_word_start(&MoveToPreviousWordStart, window, cx); + assert_selection_ranges("use ˇstd::str::{foo, bar}\n\nˇ {baz.qux()}", editor, cx); + + editor.move_to_previous_word_start(&MoveToPreviousWordStart, window, cx); + assert_selection_ranges("ˇuse std::str::{foo, bar}\nˇ\n {baz.qux()}", editor, cx); + + editor.move_to_previous_word_start(&MoveToPreviousWordStart, window, cx); + assert_selection_ranges("ˇuse std::str::{foo, barˇ}\n\n {baz.qux()}", editor, cx); + + editor.move_to_next_word_end(&MoveToNextWordEnd, window, cx); + assert_selection_ranges("useˇ std::str::{foo, bar}ˇ\n\n {baz.qux()}", editor, cx); + + editor.move_to_next_word_end(&MoveToNextWordEnd, window, cx); + assert_selection_ranges("use stdˇ::str::{foo, bar}\nˇ\n {baz.qux()}", editor, cx); + + editor.move_to_next_word_end(&MoveToNextWordEnd, window, cx); + assert_selection_ranges("use std::ˇstr::{foo, bar}\n\n {ˇbaz.qux()}", editor, cx); + + editor.move_right(&MoveRight, window, cx); + editor.select_to_previous_word_start(&SelectToPreviousWordStart, window, cx); + assert_selection_ranges( + "use std::«ˇs»tr::{foo, bar}\n\n {«ˇb»az.qux()}", + editor, + cx, + ); + + editor.select_to_previous_word_start(&SelectToPreviousWordStart, window, cx); + assert_selection_ranges( + "use std«ˇ::s»tr::{foo, bar}\n\n «ˇ{b»az.qux()}", + editor, + cx, + ); + + editor.select_to_next_word_end(&SelectToNextWordEnd, window, cx); + assert_selection_ranges( + "use std::«ˇs»tr::{foo, bar}\n\n {«ˇb»az.qux()}", + editor, + cx, + ); + }); +} + +#[gpui::test] +fn test_prev_next_word_bounds_with_soft_wrap(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let editor = cx.add_window(|window, cx| { + let buffer = MultiBuffer::build_simple("use one::{\n two::three::four::five\n};", cx); + build_editor(buffer, window, cx) + }); + + _ = editor.update(cx, |editor, window, cx| { + editor.set_wrap_width(Some(140.0.into()), cx); + assert_eq!( + editor.display_text(cx), + "use one::{\n two::three::\n four::five\n};" + ); + + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(1), 7)..DisplayPoint::new(DisplayRow(1), 7) + ]); + }); + + editor.move_to_next_word_end(&MoveToNextWordEnd, window, cx); + assert_eq!( + display_ranges(editor, cx), + &[DisplayPoint::new(DisplayRow(1), 9)..DisplayPoint::new(DisplayRow(1), 9)] + ); + + editor.move_to_next_word_end(&MoveToNextWordEnd, window, cx); + assert_eq!( + display_ranges(editor, cx), + &[DisplayPoint::new(DisplayRow(1), 14)..DisplayPoint::new(DisplayRow(1), 14)] + ); + + editor.move_to_next_word_end(&MoveToNextWordEnd, window, cx); + assert_eq!( + display_ranges(editor, cx), + &[DisplayPoint::new(DisplayRow(2), 4)..DisplayPoint::new(DisplayRow(2), 4)] + ); + + editor.move_to_next_word_end(&MoveToNextWordEnd, window, cx); + assert_eq!( + display_ranges(editor, cx), + &[DisplayPoint::new(DisplayRow(2), 8)..DisplayPoint::new(DisplayRow(2), 8)] + ); + + editor.move_to_previous_word_start(&MoveToPreviousWordStart, window, cx); + assert_eq!( + display_ranges(editor, cx), + &[DisplayPoint::new(DisplayRow(2), 4)..DisplayPoint::new(DisplayRow(2), 4)] + ); + + editor.move_to_previous_word_start(&MoveToPreviousWordStart, window, cx); + assert_eq!( + display_ranges(editor, cx), + &[DisplayPoint::new(DisplayRow(1), 14)..DisplayPoint::new(DisplayRow(1), 14)] + ); + }); +} + +#[gpui::test] +async fn test_move_start_of_paragraph_end_of_paragraph(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + + let line_height = cx.update_editor(|editor, window, cx| { + editor + .style(cx) + .text + .line_height_in_pixels(window.rem_size()) + }); + cx.simulate_window_resize(cx.window, size(px(100.), 4. * line_height)); + + // The third line only contains a single space so we can later assert that the + // editor's paragraph movement considers a non-blank line as a paragraph + // boundary. + cx.set_state(&"ˇone\ntwo\n \nthree\nfourˇ\nfive\n\nsix"); + + cx.update_editor(|editor, window, cx| { + editor.move_to_end_of_paragraph(&MoveToEndOfParagraph, window, cx) + }); + cx.assert_editor_state(&"one\ntwo\nˇ \nthree\nfour\nfive\nˇ\nsix"); + + cx.update_editor(|editor, window, cx| { + editor.move_to_end_of_paragraph(&MoveToEndOfParagraph, window, cx) + }); + cx.assert_editor_state(&"one\ntwo\n \nthree\nfour\nfive\nˇ\nsixˇ"); + + cx.update_editor(|editor, window, cx| { + editor.move_to_end_of_paragraph(&MoveToEndOfParagraph, window, cx) + }); + cx.assert_editor_state(&"one\ntwo\n \nthree\nfour\nfive\n\nsixˇ"); + + cx.update_editor(|editor, window, cx| { + editor.move_to_start_of_paragraph(&MoveToStartOfParagraph, window, cx) + }); + cx.assert_editor_state(&"one\ntwo\n \nthree\nfour\nfive\nˇ\nsix"); + + cx.update_editor(|editor, window, cx| { + editor.move_to_start_of_paragraph(&MoveToStartOfParagraph, window, cx) + }); + + cx.assert_editor_state(&"one\ntwo\nˇ \nthree\nfour\nfive\n\nsix"); + + cx.update_editor(|editor, window, cx| { + editor.move_to_start_of_paragraph(&MoveToStartOfParagraph, window, cx) + }); + cx.assert_editor_state(&"ˇone\ntwo\n \nthree\nfour\nfive\n\nsix"); +} + +#[gpui::test] +async fn test_move_to_next_and_previous_comment_paragraph(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let language = Arc::new( + Language::new( + LanguageConfig { + line_comments: vec!["// ".into()], + ..LanguageConfig::default() + }, + Some(tree_sitter_rust::LANGUAGE.into()), + ) + .with_override_query("[(line_comment)(block_comment)] @comment.inclusive") + .unwrap(), + ); + + let mut cx = EditorTestContext::new(cx).await; + cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); + + // A blank comment line (`//`) splits one comment block into two paragraphs; + // a code line splits blocks; and a trailing comment preceded by code + // (`let x = 1; // ...`) is not a comment line at all. + cx.set_state(indoc! {" + ˇ// first paragraph line one + // first paragraph line two + // + // second paragraph + fn code() {} + // third paragraph + let x = 1; // trailing comment, ignored + // fourth paragraph + "}); + cx.run_until_parked(); + + let next = |cx: &mut EditorTestContext| { + cx.update_editor(|editor, window, cx| { + editor.move_to_next_comment_paragraph(&MoveToNextCommentParagraph, window, cx) + }); + }; + let prev = |cx: &mut EditorTestContext| { + cx.update_editor(|editor, window, cx| { + editor.move_to_previous_comment_paragraph(&MoveToPreviousCommentParagraph, window, cx) + }); + }; + + // Forward: skip the second line of the first paragraph, the blank comment + // line, the code line, and the trailing comment line. + next(&mut cx); + cx.assert_editor_state(indoc! {" + // first paragraph line one + // first paragraph line two + // + ˇ// second paragraph + fn code() {} + // third paragraph + let x = 1; // trailing comment, ignored + // fourth paragraph + "}); + next(&mut cx); + cx.assert_editor_state(indoc! {" + // first paragraph line one + // first paragraph line two + // + // second paragraph + fn code() {} + ˇ// third paragraph + let x = 1; // trailing comment, ignored + // fourth paragraph + "}); + next(&mut cx); + cx.assert_editor_state(indoc! {" + // first paragraph line one + // first paragraph line two + // + // second paragraph + fn code() {} + // third paragraph + let x = 1; // trailing comment, ignored + ˇ// fourth paragraph + "}); + // No paragraph after the last one: the caret stays put. + next(&mut cx); + cx.assert_editor_state(indoc! {" + // first paragraph line one + // first paragraph line two + // + // second paragraph + fn code() {} + // third paragraph + let x = 1; // trailing comment, ignored + ˇ// fourth paragraph + "}); + + // Backward is the mirror image. + prev(&mut cx); + cx.assert_editor_state(indoc! {" + // first paragraph line one + // first paragraph line two + // + // second paragraph + fn code() {} + ˇ// third paragraph + let x = 1; // trailing comment, ignored + // fourth paragraph + "}); + prev(&mut cx); + cx.assert_editor_state(indoc! {" + // first paragraph line one + // first paragraph line two + // + ˇ// second paragraph + fn code() {} + // third paragraph + let x = 1; // trailing comment, ignored + // fourth paragraph + "}); + prev(&mut cx); + cx.assert_editor_state(indoc! {" + ˇ// first paragraph line one + // first paragraph line two + // + // second paragraph + fn code() {} + // third paragraph + let x = 1; // trailing comment, ignored + // fourth paragraph + "}); + // No paragraph before the first one: the caret stays put. + prev(&mut cx); + cx.assert_editor_state(indoc! {" + ˇ// first paragraph line one + // first paragraph line two + // + // second paragraph + fn code() {} + // third paragraph + let x = 1; // trailing comment, ignored + // fourth paragraph + "}); +} + +#[gpui::test] +async fn test_move_to_previous_comment_paragraph_skips_current_paragraph(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let language = Arc::new( + Language::new( + LanguageConfig { + line_comments: vec!["// ".into()], + ..LanguageConfig::default() + }, + Some(tree_sitter_rust::LANGUAGE.into()), + ) + .with_override_query("[(line_comment)(block_comment)] @comment.inclusive") + .unwrap(), + ); + + let mut cx = EditorTestContext::new(cx).await; + cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); + + let prev = |cx: &mut EditorTestContext| { + cx.update_editor(|editor, window, cx| { + editor.move_to_previous_comment_paragraph(&MoveToPreviousCommentParagraph, window, cx) + }); + }; + + // Caret in the middle of the last line of the second paragraph: moving to + // the previous paragraph must skip the entire current paragraph and land on + // the first paragraph's start, not on the current paragraph's own start. + cx.set_state(indoc! {" + // alpha one + // alpha two + // alpha three + + // beta one + // beta ˇtwo + "}); + cx.run_until_parked(); + prev(&mut cx); + cx.assert_editor_state(indoc! {" + ˇ// alpha one + // alpha two + // alpha three + + // beta one + // beta two + "}); + + // Same when the caret is part-way through the *first* line of the second + // paragraph. + cx.set_state(indoc! {" + // alpha one + // alpha two + // alpha three + + // beˇta one + // beta two + "}); + cx.run_until_parked(); + prev(&mut cx); + cx.assert_editor_state(indoc! {" + ˇ// alpha one + // alpha two + // alpha three + + // beta one + // beta two + "}); + + // Inside the first paragraph there is no previous paragraph, so the caret + // stays put rather than jumping to the current paragraph's own start. + cx.set_state(indoc! {" + // alpha one + // alpha ˇtwo + // alpha three + + // beta one + // beta two + "}); + cx.run_until_parked(); + prev(&mut cx); + cx.assert_editor_state(indoc! {" + // alpha one + // alpha ˇtwo + // alpha three + + // beta one + // beta two + "}); +} + +#[gpui::test] +async fn test_scroll_page_up_page_down(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + let line_height = cx.update_editor(|editor, window, cx| { + editor + .style(cx) + .text + .line_height_in_pixels(window.rem_size()) + }); + let window = cx.window; + cx.simulate_window_resize(window, size(px(1000.), 4. * line_height + px(0.5))); + + cx.set_state( + r#"ˇone + two + three + four + five + six + seven + eight + nine + ten + "#, + ); + + cx.update_editor(|editor, window, cx| { + assert_eq!( + editor.snapshot(window, cx).scroll_position(), + gpui::Point::new(0., 0.) + ); + editor.scroll_screen(&ScrollAmount::Page(1.), window, cx); + assert_eq!( + editor.snapshot(window, cx).scroll_position(), + gpui::Point::new(0., 3.) + ); + editor.scroll_screen(&ScrollAmount::Page(1.), window, cx); + assert_eq!( + editor.snapshot(window, cx).scroll_position(), + gpui::Point::new(0., 6.) + ); + editor.scroll_screen(&ScrollAmount::Page(-1.), window, cx); + assert_eq!( + editor.snapshot(window, cx).scroll_position(), + gpui::Point::new(0., 3.) + ); + + editor.scroll_screen(&ScrollAmount::Page(-0.5), window, cx); + assert_eq!( + editor.snapshot(window, cx).scroll_position(), + gpui::Point::new(0., 1.) + ); + editor.scroll_screen(&ScrollAmount::Page(0.5), window, cx); + assert_eq!( + editor.snapshot(window, cx).scroll_position(), + gpui::Point::new(0., 3.) + ); + }); +} + +#[gpui::test] +async fn test_newest_selection_on_screen_with_multibyte_chars(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + + let multibyte_line = "ã".repeat(40); + cx.set_state(&format!("{multibyte_line}ˇx\n")); + cx.update_editor(|editor, window, cx| { + editor.set_visible_line_count(50., window, cx); + editor.set_visible_column_count(60.); + assert_eq!( + editor.newest_selection_on_screen(window, cx), + Ordering::Equal + ); + editor.set_visible_column_count(20.); + assert_eq!( + editor.newest_selection_on_screen(window, cx), + Ordering::Greater + ); + }); + + let ascii_line = "a".repeat(40); + cx.set_state(&format!("{ascii_line}ˇx\n")); + cx.update_editor(|editor, window, cx| { + editor.set_visible_line_count(50., window, cx); + editor.set_visible_column_count(60.); + assert_eq!( + editor.newest_selection_on_screen(window, cx), + Ordering::Equal + ); + editor.set_visible_column_count(20.); + assert_eq!( + editor.newest_selection_on_screen(window, cx), + Ordering::Greater + ); + }); +} + +#[gpui::test] +async fn test_scroll_line_up_down_cursor_margin(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + let line_height = cx.update_editor(|editor, window, cx| { + editor.set_vertical_scroll_margin(2, cx); + editor + .style(cx) + .text + .line_height_in_pixels(window.rem_size()) + }); + let window = cx.window; + // 5 visible lines with margin=2: valid cursor rows are [top+2 .. top+2] (only the middle row) + cx.simulate_window_resize(window, size(px(1000.), 5. * line_height)); + + // Cursor at row 0 — autoscroll leaves viewport at y=0. + cx.set_state(indoc! {" + ˇone + two + three + four + five + six + seven + eight + nine + ten + "}); + + cx.update_editor(|editor, window, cx| { + assert_eq!(editor.snapshot(window, cx).scroll_position().y, 0.); + + // Scroll down 1: top=1, visible rows 1-5, margin=2 → min_row=3, max_row=3. + // Cursor at row 0 < min_row=3 → clamped to row 3. + editor.scroll_screen_with_cursor_margin(&ScrollAmount::Line(1.), window, cx); + assert_eq!(editor.snapshot(window, cx).scroll_position().y, 1.); + let snapshot = editor.display_snapshot(cx); + assert_eq!( + editor.selections.newest_display(&snapshot).head().row().0, + 3, + "cursor clamped to min_row after scrolling down" + ); + }); + + cx.update_editor(|editor, window, cx| { + // Scroll up 1: top=0, visible rows 0-4, margin=2 (top==0 special case) → min_row=0, max_row=2. + // Cursor at row 3 > max_row=2 → clamped to row 2. + editor.scroll_screen_with_cursor_margin(&ScrollAmount::Line(-1.), window, cx); + assert_eq!(editor.snapshot(window, cx).scroll_position().y, 0.); + let snapshot = editor.display_snapshot(cx); + assert_eq!( + editor.selections.newest_display(&snapshot).head().row().0, + 2, + "cursor clamped to max_row after scrolling up" + ); + }); + + cx.update_editor(|editor, window, cx| { + // Half page down (2 lines): top=2, visible rows 2-6, margin=2 → min_row=4, max_row=4. + // Cursor at row 2 < min_row=4 → clamped to row 4. + editor.scroll_screen_with_cursor_margin(&ScrollAmount::Page(0.5), window, cx); + assert_eq!(editor.snapshot(window, cx).scroll_position().y, 2.); + let snapshot = editor.display_snapshot(cx); + assert_eq!( + editor.selections.newest_display(&snapshot).head().row().0, + 4, + "cursor clamped to min_row after scrolling half page down" + ); + }); + + cx.update_editor(|editor, window, cx| { + // Half page up (2 lines): top=0, visible rows 0-4, margin=2 (top==0 special case) → min_row=0, max_row=2. + // Cursor at row 4 > max_row=2 → clamped to row 2. + editor.scroll_screen_with_cursor_margin(&ScrollAmount::Page(-0.5), window, cx); + assert_eq!(editor.snapshot(window, cx).scroll_position().y, 0.); + let snapshot = editor.display_snapshot(cx); + assert_eq!( + editor.selections.newest_display(&snapshot).head().row().0, + 2, + "cursor clamped to max_row after scrolling half page up" + ); + }); +} + +#[gpui::test] +async fn test_autoscroll(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + + let line_height = cx.update_editor(|editor, window, cx| { + editor.set_vertical_scroll_margin(2, cx); + editor + .style(cx) + .text + .line_height_in_pixels(window.rem_size()) + }); + let window = cx.window; + cx.simulate_window_resize(window, size(px(1000.), 6. * line_height)); + + cx.set_state( + r#"ˇone + two + three + four + five + six + seven + eight + nine + ten + "#, + ); + cx.update_editor(|editor, window, cx| { + assert_eq!( + editor.snapshot(window, cx).scroll_position(), + gpui::Point::new(0., 0.0) + ); + }); + + // Add a cursor below the visible area. Since both cursors cannot fit + // on screen, the editor autoscrolls to reveal the newest cursor, and + // allows the vertical scroll margin below that cursor. + cx.update_editor(|editor, window, cx| { + editor.change_selections(Default::default(), window, cx, |selections| { + selections.select_ranges([ + Point::new(0, 0)..Point::new(0, 0), + Point::new(6, 0)..Point::new(6, 0), + ]); + }) + }); + cx.update_editor(|editor, window, cx| { + assert_eq!( + editor.snapshot(window, cx).scroll_position(), + gpui::Point::new(0., 3.0) + ); + }); + + // Move down. The editor cursor scrolls down to track the newest cursor. + cx.update_editor(|editor, window, cx| { + editor.move_down(&Default::default(), window, cx); + }); + cx.update_editor(|editor, window, cx| { + assert_eq!( + editor.snapshot(window, cx).scroll_position(), + gpui::Point::new(0., 4.0) + ); + }); + + // Add a cursor above the visible area. Since both cursors fit on screen, + // the editor scrolls to show both. + cx.update_editor(|editor, window, cx| { + editor.change_selections(Default::default(), window, cx, |selections| { + selections.select_ranges([ + Point::new(1, 0)..Point::new(1, 0), + Point::new(6, 0)..Point::new(6, 0), + ]); + }) + }); + cx.update_editor(|editor, window, cx| { + assert_eq!( + editor.snapshot(window, cx).scroll_position(), + gpui::Point::new(0., 1.0) + ); + }); +} + +#[track_caller] +fn assert_cursor_row_and_scroll_top( + cx: &mut EditorTestContext, + expected_cursor_row: u32, + expected_scroll_top: f64, +) { + cx.update_editor(|editor, window, cx| { + let snapshot = editor.snapshot(window, cx); + let cursor_row = editor + .selections + .newest_display(&snapshot.display_snapshot) + .head() + .row(); + + assert_eq!(cursor_row, DisplayRow(expected_cursor_row)); + assert_eq!(snapshot.scroll_position().y, expected_scroll_top); + }); +} + +#[gpui::test] +async fn test_autoscroll_high_margin_keeps_cursor_centered(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + + let line_height = cx.update_editor(|editor, window, cx| { + // Let viewport space, rather than the configured margin, limit the padding. + editor.set_vertical_scroll_margin(999, cx); + editor + .style(cx) + .text + .line_height_in_pixels(window.rem_size()) + }); + let window = cx.window; + + for autoscroll in [Autoscroll::fit(), Autoscroll::newest()] { + // After allowing for the cursor row, the free space is odd, fractional, or even. + for (visible_lines, expected_top_margin) in [(6., 2.), (6.5, 2.), (7., 3.)] { + cx.simulate_window_resize(window, size(px(1000.), visible_lines * line_height)); + + // Start at zero-based row 6, away from file boundaries that could clamp scrolling. + cx.set_state("0\n1\n2\n3\n4\n5\nˇ6\n7\n8\n9\n10\n11\n12\n13\n14"); + cx.update_editor(|editor, window, cx| { + editor.set_scroll_position(gpui::Point::new(0., 0.), window, cx); + editor.request_autoscroll(autoscroll, cx); + }); + cx.run_until_parked(); + + // The cursor's viewport offset is its row minus the scroll top. + assert_cursor_row_and_scroll_top(&mut cx, 6, 6. - expected_top_margin); + + // Moving down to row 7 must scroll with the cursor, preserving that offset. + cx.update_editor(|editor, window, cx| { + editor.move_down(&MoveDown, window, cx); + editor.request_autoscroll(autoscroll, cx); + }); + cx.run_until_parked(); + assert_cursor_row_and_scroll_top(&mut cx, 7, 7. - expected_top_margin); + + // Returning to row 6 must restore the viewport, not leave room for cursor drift. + cx.update_editor(|editor, window, cx| { + editor.move_up(&MoveUp, window, cx); + editor.request_autoscroll(autoscroll, cx); + }); + cx.run_until_parked(); + assert_cursor_row_and_scroll_top(&mut cx, 6, 6. - expected_top_margin); + } + } +} + +#[gpui::test] +async fn test_autoscroll_auto_height_has_no_margin(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + + let line_height = cx.update_editor(|editor, window, cx| { + editor.set_mode(EditorMode::AutoHeight { + min_lines: 1, + max_lines: Some(4), + }); + + // Auto-height inputs must ignore even a nonzero configured margin. + editor.set_vertical_scroll_margin(3, cx); + editor + .style(cx) + .text + .line_height_in_pixels(window.rem_size()) + }); + let window = cx.window; + cx.simulate_window_resize(window, size(px(1000.), 4. * line_height)); + cx.set_state("ˇ0\n1\n2\n3\n4\n5\n6\n7\n8\n9"); + cx.update_editor(|editor, _, _| { + assert_eq!(editor.visible_line_count(), Some(4.)); + }); + + // Row 2 is already visible, so moving there must not reserve space below it. + cx.update_editor(|editor, window, cx| { + editor.move_down_by_lines(&MoveDownByLines { lines: 2 }, window, cx); + }); + cx.run_until_parked(); + assert_cursor_row_and_scroll_top(&mut cx, 2, 0.); + + // Row 4 is just outside the viewport: scroll only far enough to reveal it. + cx.update_editor(|editor, window, cx| { + editor.move_down_by_lines(&MoveDownByLines { lines: 2 }, window, cx); + }); + cx.run_until_parked(); + assert_cursor_row_and_scroll_top(&mut cx, 4, 1.); +} + +#[gpui::test] +async fn test_cursor_animation_remains_active_during_keyboard_autoscroll(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + update_test_editor_settings(cx, &|settings| { + settings.cursor_animation.get_or_insert_default().enabled = Some(true); + }); + let mut cx = EditorTestContext::new(cx).await; + + let line_height = cx.update_editor(|editor, window, cx| { + editor.set_vertical_scroll_margin(3, cx); + editor.set_cursor_shape(CursorShape::Block, cx); + editor + .style(cx) + .text + .line_height_in_pixels(window.rem_size()) + }); + let window = cx.window; + cx.simulate_window_resize(window, size(px(1000.0), 8.0 * line_height)); + + let text = (0..40) + .map(|row| format!("line {row}")) + .collect::>() + .join("\n"); + cx.set_state(&format!("ˇ{text}")); + cx.update(|window, cx| { + window.refresh(); + let _ = window.draw(cx); + }); + + let mut previous_scroll_y = 0.0; + let mut observed_autoscroll = false; + for _ in 0..20 { + cx.update_editor(|editor, window, cx| { + editor.move_down(&Default::default(), window, cx); + }); + cx.update(|window, cx| { + window.refresh(); + let _ = window.draw(cx); + }); + + let (scroll_y, animation_active) = cx.update_editor(|editor, window, cx| { + ( + editor.snapshot(window, cx).scroll_position().y, + editor.cursor_animations.has_active_animation(), + ) + }); + if scroll_y > previous_scroll_y { + assert!( + animation_active, + "cursor animation should remain active when keyboard movement autoscrolls" + ); + observed_autoscroll = true; + break; + } + previous_scroll_y = scroll_y; + } + + assert!( + observed_autoscroll, + "expected cursor movement to autoscroll" + ); +} + +#[gpui::test] +async fn test_autoscroll_relative(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + + let line_height = cx.update_editor(|editor, window, cx| { + editor.set_vertical_scroll_margin(0, cx); + editor + .style(cx) + .text + .line_height_in_pixels(window.rem_size()) + }); + let window = cx.window; + + // Resize the window such that only 6 lines of text fit on screen. + cx.simulate_window_resize(window, size(px(1000.), 6. * line_height)); + + cx.set_state( + r#"ˇone + two + three + four + five + six + seven + eight + nine + ten + eleven + twelve + thirteen + fourteen + fifteen + "#, + ); + cx.update_editor(|editor, window, cx| { + assert_eq!( + editor.snapshot(window, cx).scroll_position(), + gpui::Point::new(0., 0.0) + ); + }); + + // Placing the cursor at row 7 with a top-relative autoscroll of 2 display + // rows, should land the scroll position's y coordinate at 5.0 (7 - 2). + cx.update_editor(|editor, window, cx| { + editor.change_selections( + SelectionEffects::scroll(Autoscroll::top_relative(2.0)), + window, + cx, + |selections| selections.select_ranges([Point::new(7, 0)..Point::new(7, 0)]), + ); + }); + cx.update_editor(|editor, window, cx| { + assert_eq!( + editor.snapshot(window, cx).scroll_position(), + gpui::Point::new(0., 5.0) + ); + }); + + // Seeing as fractional offsets are supported, with the cursor at row 10 and + // a top-relative autoscroll of 2.5 display rows, the scroll position's y + // coordinate lands at 7.5 (10 - 2.5). + cx.update_editor(|editor, window, cx| { + editor.change_selections( + SelectionEffects::scroll(Autoscroll::top_relative(2.5)), + window, + cx, + |selections| selections.select_ranges([Point::new(10, 0)..Point::new(10, 0)]), + ); + }); + cx.update_editor(|editor, window, cx| { + assert_eq!( + editor.snapshot(window, cx).scroll_position(), + gpui::Point::new(0., 7.5) + ); + }); + + // When the requested offset would scroll past the top of the buffer, + // `scroll_position.y` is clamped to 0 rather than going negative. + cx.update_editor(|editor, window, cx| { + editor.change_selections( + SelectionEffects::scroll(Autoscroll::top_relative(4.0)), + window, + cx, + |selections| selections.select_ranges([Point::new(1, 0)..Point::new(1, 0)]), + ); + }); + + cx.update_editor(|editor, window, cx| { + assert_eq!( + editor.snapshot(window, cx).scroll_position(), + gpui::Point::new(0., 0.0) + ); + }); +} + +#[gpui::test] +async fn test_autoscroll_horizontally_long_selection_tracks_cursor(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + + let window = cx.window; + // Narrow viewport so a long line can't possibly fit on screen at once. + cx.simulate_window_resize(window, size(px(200.), px(300.))); + + let long_line = "x".repeat(250); + cx.set_state(&format!("ˇ{long_line}")); + + cx.update_editor(|editor, window, cx| { + assert_eq!( + editor.snapshot(window, cx).scroll_position(), + gpui::Point::new(0., 0.0) + ); + }); + + // Simulate shift+end: extend the selection from column 0 to the end + // of the long line. Before the fix, this selection's span was wider + // than the viewport, which caused autoscroll_horizontally to bail + // out entirely and leave scroll_position.x at 0. + cx.update_editor(|editor, window, cx| { + editor.change_selections(Default::default(), window, cx, |selections| { + selections.select_ranges([Point::new(0, 0)..Point::new(0, 250)]); + }); + }); + cx.run_until_parked(); + + cx.update_editor(|editor, window, cx| { + let scroll_x = editor.snapshot(window, cx).scroll_position().x; + let visible_columns = editor.visible_column_count().unwrap(); + let right_edge = scroll_x + visible_columns; + assert!( + scroll_x <= 250., + "head must not be scrolled past, scroll_x = {scroll_x}" + ); + assert!( + right_edge > 249., + "head column must be at the right edge, visible columns end at {right_edge}" + ); + }); +} + +#[gpui::test] +async fn test_autoscroll_horizontally_padded_span_boundary_still_scrolls(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + let window = cx.window; + + // Long line so scroll_width stays large and never clamps target_right, + // isolating the case below. Viewport is tuned so a 5-char selection's + // raw glyph span fits, but its padded span (+ gutter margin + em_advance, + // the same padding the final scroll guard uses) does not. + cx.simulate_window_resize(window, size(px(146.), px(300.))); + + let line = "x".repeat(300); + cx.set_state(&format!("ˇ{line}")); + + cx.update_editor(|editor, window, cx| { + assert_eq!( + editor.snapshot(window, cx).scroll_position(), + gpui::Point::new(0., 0.0) + ); + }); + + // Select a short span in the middle of the line (e.g. simulating a + // search match). Its raw glyph span fits the viewport, but the padded + // span does not, so the fix must still scroll to reveal it. + cx.update_editor(|editor, window, cx| { + editor.change_selections(Default::default(), window, cx, |selections| { + selections.select_ranges([Point::new(0, 100)..Point::new(0, 105)]); + }); + }); + cx.run_until_parked(); + + cx.update_editor(|editor, window, cx| { + let scroll_x = editor.snapshot(window, cx).scroll_position().x; + let visible_columns = editor.visible_column_count().unwrap(); + let right_edge = scroll_x + visible_columns; + assert!( + scroll_x <= 105., + "selection head must be visible, scroll_x = {scroll_x}" + ); + assert!( + right_edge > 105., + "selection head must be visible, visible columns end at {right_edge}" + ); + }); +} + +#[gpui::test] +async fn test_autoscroll_horizontally_fitting_selection_reveals_full_span(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + let window = cx.window; + + // Viewport wide enough to fit a short selection's full span at once. + cx.simulate_window_resize(window, size(px(200.), px(300.))); + + let line = "x".repeat(300); + cx.set_state(&format!("ˇ{line}")); + + // Scroll right first so the whole line is out of view. + cx.update_editor(|editor, window, cx| { + editor.change_selections(Default::default(), window, cx, |selections| { + selections.select_ranges([Point::new(0, 300)..Point::new(0, 300)]); + }); + }); + cx.run_until_parked(); + + let scrolled_right_x = cx.update_editor(|editor, window, cx| { + let scroll_x = editor.snapshot(window, cx).scroll_position().x; + assert!( + scroll_x > 200., + "should be scrolled far right, scroll_x = {scroll_x}" + ); + scroll_x + }); + + // Select a short span in the middle of the line (e.g. simulating a + // search match). Since it fits within the viewport, both edges of + // the selection should end up visible, not just the head. + cx.update_editor(|editor, window, cx| { + editor.change_selections(Default::default(), window, cx, |selections| { + selections.select_ranges([Point::new(0, 100)..Point::new(0, 105)]); + }); + }); + cx.run_until_parked(); + + cx.update_editor(|editor, window, cx| { + let scroll_x = editor.snapshot(window, cx).scroll_position().x; + let visible_columns = editor.visible_column_count().unwrap(); + let right_edge = scroll_x + visible_columns; + assert!( + scroll_x < scrolled_right_x, + "must scroll left towards the selection, scroll_x = {scroll_x}" + ); + assert!( + scroll_x <= 100.5, + "selection start must be visible, scroll_x = {scroll_x}" + ); + assert!( + right_edge > 105., + "selection end must be visible, visible columns end at {right_edge}" + ); + }); +} + +#[gpui::test] +async fn test_exclude_overscroll_margin_clamps_scroll_position(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + update_test_editor_settings(cx, &|settings| { + settings.scroll_beyond_last_line = Some(ScrollBeyondLastLine::OnePage); + }); + + let mut cx = EditorTestContext::new(cx).await; + + let line_height = cx.update_editor(|editor, window, cx| { + editor.set_mode(EditorMode::Full { + scale_ui_elements_with_buffer_font_size: false, + show_active_line_background: false, + sizing_behavior: SizingBehavior::ExcludeOverscrollMargin, + }); + editor + .style(cx) + .text + .line_height_in_pixels(window.rem_size()) + }); + let window = cx.window; + cx.simulate_window_resize(window, size(px(1000.), 6. * line_height)); + cx.set_state( + &r#" + ˇone + two + three + four + five + six + seven + eight + nine + ten + eleven + "# + .unindent(), + ); + + cx.update_editor(|editor, window, cx| { + let snapshot = editor.snapshot(window, cx); + let max_scroll_top = + (snapshot.max_point().row().as_f64() - editor.visible_line_count().unwrap() + 1.) + .max(0.); + + editor.set_scroll_position(gpui::Point::new(0., max_scroll_top + 10.), window, cx); + + assert_eq!( + editor.snapshot(window, cx).scroll_position(), + gpui::Point::new(0., max_scroll_top) + ); + }); +} + +#[gpui::test] +async fn test_move_page_up_page_down(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + + let line_height = cx.update_editor(|editor, window, cx| { + editor + .style(cx) + .text + .line_height_in_pixels(window.rem_size()) + }); + let window = cx.window; + cx.simulate_window_resize(window, size(px(100.), 4. * line_height)); + cx.set_state( + &r#" + ˇone + two + threeˇ + four + five + six + seven + eight + nine + ten + "# + .unindent(), + ); + + cx.update_editor(|editor, window, cx| { + editor.move_page_down(&MovePageDown::default(), window, cx) + }); + cx.assert_editor_state( + &r#" + one + two + three + ˇfour + five + sixˇ + seven + eight + nine + ten + "# + .unindent(), + ); + + cx.update_editor(|editor, window, cx| { + editor.move_page_down(&MovePageDown::default(), window, cx) + }); + cx.assert_editor_state( + &r#" + one + two + three + four + five + six + ˇseven + eight + nineˇ + ten + "# + .unindent(), + ); + + cx.update_editor(|editor, window, cx| editor.move_page_up(&MovePageUp::default(), window, cx)); + cx.assert_editor_state( + &r#" + one + two + three + ˇfour + five + sixˇ + seven + eight + nine + ten + "# + .unindent(), + ); + + cx.update_editor(|editor, window, cx| editor.move_page_up(&MovePageUp::default(), window, cx)); + cx.assert_editor_state( + &r#" + ˇone + two + threeˇ + four + five + six + seven + eight + nine + ten + "# + .unindent(), + ); + + // Test select collapsing + cx.update_editor(|editor, window, cx| { + editor.move_page_down(&MovePageDown::default(), window, cx); + editor.move_page_down(&MovePageDown::default(), window, cx); + editor.move_page_down(&MovePageDown::default(), window, cx); + }); + cx.assert_editor_state( + &r#" + one + two + three + four + five + six + seven + eight + nine + ˇten + ˇ"# + .unindent(), + ); +} + +#[gpui::test] +async fn test_delete_to_line_boundary(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + + for selection in ["one «two threeˇ» four", "one «ˇtwo three» four"] { + cx.set_state(selection); + cx.update_editor(|editor, window, cx| { + editor.delete_to_beginning_of_line( + &DeleteToBeginningOfLine { + stop_at_indent: false, + }, + window, + cx, + ); + assert_eq!(editor.text(cx), " four"); + }); + + cx.set_state(selection); + cx.update_editor(|editor, window, cx| { + editor.delete_to_end_of_line(&DeleteToEndOfLine, window, cx); + assert_eq!(editor.text(cx), "one "); + }); + } +} + +#[gpui::test] +async fn test_delete_to_word_boundary(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + // For an empty selection, the preceding word fragment is deleted. + // For non-empty selections, only selected characters are deleted. + cx.set_state("onˇe two t«hreˇ»e four"); + cx.update_editor(|editor, window, cx| { + editor.delete_to_previous_word_start( + &DeleteToPreviousWordStart { + ignore_newlines: false, + ignore_brackets: false, + }, + window, + cx, + ); + }); + cx.assert_editor_state("ˇe two tˇe four"); + + cx.set_state("e tˇwo te «fˇ»our"); + cx.update_editor(|editor, window, cx| { + editor.delete_to_next_word_end( + &DeleteToNextWordEnd { + ignore_newlines: false, + ignore_brackets: false, + }, + window, + cx, + ); + }); + cx.assert_editor_state("e tˇ te ˇour"); +} + +#[gpui::test] +async fn test_delete_whitespaces(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + cx.set_state("here is some text ˇwith a space"); + cx.update_editor(|editor, window, cx| { + editor.delete_to_previous_word_start( + &DeleteToPreviousWordStart { + ignore_newlines: false, + ignore_brackets: true, + }, + window, + cx, + ); + }); + // Continuous whitespace sequences are removed entirely, words behind them are not affected by the deletion action. + cx.assert_editor_state("here is some textˇwith a space"); + + cx.set_state("here is some text ˇwith a space"); + cx.update_editor(|editor, window, cx| { + editor.delete_to_previous_word_start( + &DeleteToPreviousWordStart { + ignore_newlines: false, + ignore_brackets: false, + }, + window, + cx, + ); + }); + cx.assert_editor_state("here is some textˇwith a space"); + + cx.set_state("here is some textˇ with a space"); + cx.update_editor(|editor, window, cx| { + editor.delete_to_next_word_end( + &DeleteToNextWordEnd { + ignore_newlines: false, + ignore_brackets: true, + }, + window, + cx, + ); + }); + // Same happens in the other direction. + cx.assert_editor_state("here is some textˇwith a space"); + + cx.set_state("here is some textˇ with a space"); + cx.update_editor(|editor, window, cx| { + editor.delete_to_next_word_end( + &DeleteToNextWordEnd { + ignore_newlines: false, + ignore_brackets: false, + }, + window, + cx, + ); + }); + cx.assert_editor_state("here is some textˇwith a space"); + + cx.set_state("here is some textˇ with a space"); + cx.update_editor(|editor, window, cx| { + editor.delete_to_next_word_end( + &DeleteToNextWordEnd { + ignore_newlines: true, + ignore_brackets: false, + }, + window, + cx, + ); + }); + cx.assert_editor_state("here is some textˇwith a space"); + cx.update_editor(|editor, window, cx| { + editor.delete_to_previous_word_start( + &DeleteToPreviousWordStart { + ignore_newlines: true, + ignore_brackets: false, + }, + window, + cx, + ); + }); + cx.assert_editor_state("here is some ˇwith a space"); + cx.update_editor(|editor, window, cx| { + editor.delete_to_previous_word_start( + &DeleteToPreviousWordStart { + ignore_newlines: true, + ignore_brackets: false, + }, + window, + cx, + ); + }); + // Single whitespaces are removed with the word behind them. + cx.assert_editor_state("here is ˇwith a space"); + cx.update_editor(|editor, window, cx| { + editor.delete_to_previous_word_start( + &DeleteToPreviousWordStart { + ignore_newlines: true, + ignore_brackets: false, + }, + window, + cx, + ); + }); + cx.assert_editor_state("here ˇwith a space"); + cx.update_editor(|editor, window, cx| { + editor.delete_to_previous_word_start( + &DeleteToPreviousWordStart { + ignore_newlines: true, + ignore_brackets: false, + }, + window, + cx, + ); + }); + cx.assert_editor_state("ˇwith a space"); + cx.update_editor(|editor, window, cx| { + editor.delete_to_previous_word_start( + &DeleteToPreviousWordStart { + ignore_newlines: true, + ignore_brackets: false, + }, + window, + cx, + ); + }); + cx.assert_editor_state("ˇwith a space"); + cx.update_editor(|editor, window, cx| { + editor.delete_to_next_word_end( + &DeleteToNextWordEnd { + ignore_newlines: true, + ignore_brackets: false, + }, + window, + cx, + ); + }); + // Same happens in the other direction. + cx.assert_editor_state("ˇ a space"); + cx.update_editor(|editor, window, cx| { + editor.delete_to_next_word_end( + &DeleteToNextWordEnd { + ignore_newlines: true, + ignore_brackets: false, + }, + window, + cx, + ); + }); + cx.assert_editor_state("ˇ space"); + cx.update_editor(|editor, window, cx| { + editor.delete_to_next_word_end( + &DeleteToNextWordEnd { + ignore_newlines: true, + ignore_brackets: false, + }, + window, + cx, + ); + }); + cx.assert_editor_state("ˇ"); + cx.update_editor(|editor, window, cx| { + editor.delete_to_next_word_end( + &DeleteToNextWordEnd { + ignore_newlines: true, + ignore_brackets: false, + }, + window, + cx, + ); + }); + cx.assert_editor_state("ˇ"); + cx.update_editor(|editor, window, cx| { + editor.delete_to_previous_word_start( + &DeleteToPreviousWordStart { + ignore_newlines: true, + ignore_brackets: false, + }, + window, + cx, + ); + }); + cx.assert_editor_state("ˇ"); +} + +#[gpui::test] +async fn test_delete_to_bracket(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let language = Arc::new( + Language::new( + LanguageConfig { + brackets: BracketPairConfig { + pairs: vec![ + BracketPair { + start: "\"".to_string(), + end: "\"".to_string(), + close: true, + surround: true, + newline: false, + }, + BracketPair { + start: "(".to_string(), + end: ")".to_string(), + close: true, + surround: true, + newline: true, + }, + ], + ..BracketPairConfig::default() + }, + ..LanguageConfig::default() + }, + Some(tree_sitter_rust::LANGUAGE.into()), + ) + .with_brackets_query( + r#" + ("(" @open ")" @close) + ("\"" @open "\"" @close) + "#, + ) + .unwrap(), + ); + + let mut cx = EditorTestContext::new(cx).await; + cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); + + cx.set_state(r#"macro!("// ˇCOMMENT");"#); + cx.update_editor(|editor, window, cx| { + editor.delete_to_previous_word_start( + &DeleteToPreviousWordStart { + ignore_newlines: true, + ignore_brackets: false, + }, + window, + cx, + ); + }); + // Deletion stops before brackets if asked to not ignore them. + cx.assert_editor_state(r#"macro!("ˇCOMMENT");"#); + cx.update_editor(|editor, window, cx| { + editor.delete_to_previous_word_start( + &DeleteToPreviousWordStart { + ignore_newlines: true, + ignore_brackets: false, + }, + window, + cx, + ); + }); + // Deletion has to remove a single bracket and then stop again. + cx.assert_editor_state(r#"macro!(ˇCOMMENT");"#); + + cx.update_editor(|editor, window, cx| { + editor.delete_to_previous_word_start( + &DeleteToPreviousWordStart { + ignore_newlines: true, + ignore_brackets: false, + }, + window, + cx, + ); + }); + cx.assert_editor_state(r#"macro!ˇCOMMENT");"#); + + cx.update_editor(|editor, window, cx| { + editor.delete_to_previous_word_start( + &DeleteToPreviousWordStart { + ignore_newlines: true, + ignore_brackets: false, + }, + window, + cx, + ); + }); + cx.assert_editor_state(r#"ˇCOMMENT");"#); + + cx.update_editor(|editor, window, cx| { + editor.delete_to_previous_word_start( + &DeleteToPreviousWordStart { + ignore_newlines: true, + ignore_brackets: false, + }, + window, + cx, + ); + }); + cx.assert_editor_state(r#"ˇCOMMENT");"#); + + cx.update_editor(|editor, window, cx| { + editor.delete_to_next_word_end( + &DeleteToNextWordEnd { + ignore_newlines: true, + ignore_brackets: false, + }, + window, + cx, + ); + }); + // Brackets on the right are not paired anymore, hence deletion does not stop at them + cx.assert_editor_state(r#"ˇ");"#); + + cx.update_editor(|editor, window, cx| { + editor.delete_to_next_word_end( + &DeleteToNextWordEnd { + ignore_newlines: true, + ignore_brackets: false, + }, + window, + cx, + ); + }); + cx.assert_editor_state(r#"ˇ"#); + + cx.update_editor(|editor, window, cx| { + editor.delete_to_next_word_end( + &DeleteToNextWordEnd { + ignore_newlines: true, + ignore_brackets: false, + }, + window, + cx, + ); + }); + cx.assert_editor_state(r#"ˇ"#); + + cx.set_state(r#"macro!("// ˇCOMMENT");"#); + cx.update_editor(|editor, window, cx| { + editor.delete_to_previous_word_start( + &DeleteToPreviousWordStart { + ignore_newlines: true, + ignore_brackets: true, + }, + window, + cx, + ); + }); + cx.assert_editor_state(r#"macroˇCOMMENT");"#); +} + +#[gpui::test] +fn test_delete_to_previous_word_start_or_newline(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let editor = cx.add_window(|window, cx| { + let buffer = MultiBuffer::build_simple("one\n2\nthree\n4", cx); + build_editor(buffer, window, cx) + }); + let del_to_prev_word_start = DeleteToPreviousWordStart { + ignore_newlines: false, + ignore_brackets: false, + }; + let del_to_prev_word_start_ignore_newlines = DeleteToPreviousWordStart { + ignore_newlines: true, + ignore_brackets: false, + }; + + _ = editor.update(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(3), 1)..DisplayPoint::new(DisplayRow(3), 1) + ]) + }); + editor.delete_to_previous_word_start(&del_to_prev_word_start, window, cx); + assert_eq!(editor.buffer.read(cx).read(cx).text(), "one\n2\nthree\n"); + editor.delete_to_previous_word_start(&del_to_prev_word_start, window, cx); + assert_eq!(editor.buffer.read(cx).read(cx).text(), "one\n2\nthree"); + editor.delete_to_previous_word_start(&del_to_prev_word_start, window, cx); + assert_eq!(editor.buffer.read(cx).read(cx).text(), "one\n2\n"); + editor.delete_to_previous_word_start(&del_to_prev_word_start, window, cx); + assert_eq!(editor.buffer.read(cx).read(cx).text(), "one\n2"); + editor.delete_to_previous_word_start(&del_to_prev_word_start_ignore_newlines, window, cx); + assert_eq!(editor.buffer.read(cx).read(cx).text(), "one\n"); + editor.delete_to_previous_word_start(&del_to_prev_word_start_ignore_newlines, window, cx); + assert_eq!(editor.buffer.read(cx).read(cx).text(), ""); + }); +} + +#[gpui::test] +fn test_delete_to_previous_subword_start_or_newline(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let editor = cx.add_window(|window, cx| { + let buffer = MultiBuffer::build_simple("fooBar\n\nbazQux", cx); + build_editor(buffer, window, cx) + }); + let del_to_prev_sub_word_start = DeleteToPreviousSubwordStart { + ignore_newlines: false, + ignore_brackets: false, + }; + let del_to_prev_sub_word_start_ignore_newlines = DeleteToPreviousSubwordStart { + ignore_newlines: true, + ignore_brackets: false, + }; + + _ = editor.update(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(2), 6)..DisplayPoint::new(DisplayRow(2), 6) + ]) + }); + editor.delete_to_previous_subword_start(&del_to_prev_sub_word_start, window, cx); + assert_eq!(editor.buffer.read(cx).read(cx).text(), "fooBar\n\nbaz"); + editor.delete_to_previous_subword_start(&del_to_prev_sub_word_start, window, cx); + assert_eq!(editor.buffer.read(cx).read(cx).text(), "fooBar\n\n"); + editor.delete_to_previous_subword_start(&del_to_prev_sub_word_start, window, cx); + assert_eq!(editor.buffer.read(cx).read(cx).text(), "fooBar\n"); + editor.delete_to_previous_subword_start(&del_to_prev_sub_word_start, window, cx); + assert_eq!(editor.buffer.read(cx).read(cx).text(), "fooBar"); + editor.delete_to_previous_subword_start(&del_to_prev_sub_word_start, window, cx); + assert_eq!(editor.buffer.read(cx).read(cx).text(), "foo"); + editor.delete_to_previous_subword_start( + &del_to_prev_sub_word_start_ignore_newlines, + window, + cx, + ); + assert_eq!(editor.buffer.read(cx).read(cx).text(), ""); + }); +} + +#[gpui::test] +fn test_delete_to_next_word_end_or_newline(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let editor = cx.add_window(|window, cx| { + let buffer = MultiBuffer::build_simple("\none\n two\nthree\n four", cx); + build_editor(buffer, window, cx) + }); + let del_to_next_word_end = DeleteToNextWordEnd { + ignore_newlines: false, + ignore_brackets: false, + }; + let del_to_next_word_end_ignore_newlines = DeleteToNextWordEnd { + ignore_newlines: true, + ignore_brackets: false, + }; + + _ = editor.update(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 0) + ]) + }); + editor.delete_to_next_word_end(&del_to_next_word_end, window, cx); + assert_eq!( + editor.buffer.read(cx).read(cx).text(), + "one\n two\nthree\n four" + ); + editor.delete_to_next_word_end(&del_to_next_word_end, window, cx); + assert_eq!( + editor.buffer.read(cx).read(cx).text(), + "\n two\nthree\n four" + ); + editor.delete_to_next_word_end(&del_to_next_word_end, window, cx); + assert_eq!( + editor.buffer.read(cx).read(cx).text(), + "two\nthree\n four" + ); + editor.delete_to_next_word_end(&del_to_next_word_end, window, cx); + assert_eq!(editor.buffer.read(cx).read(cx).text(), "\nthree\n four"); + editor.delete_to_next_word_end(&del_to_next_word_end_ignore_newlines, window, cx); + assert_eq!(editor.buffer.read(cx).read(cx).text(), "\n four"); + editor.delete_to_next_word_end(&del_to_next_word_end_ignore_newlines, window, cx); + assert_eq!(editor.buffer.read(cx).read(cx).text(), "four"); + editor.delete_to_next_word_end(&del_to_next_word_end_ignore_newlines, window, cx); + assert_eq!(editor.buffer.read(cx).read(cx).text(), ""); + }); +} + +#[gpui::test] +fn test_delete_to_next_subword_end_or_newline(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let editor = cx.add_window(|window, cx| { + let buffer = MultiBuffer::build_simple("\nfooBar\n bazQux", cx); + build_editor(buffer, window, cx) + }); + let del_to_next_subword_end = DeleteToNextSubwordEnd { + ignore_newlines: false, + ignore_brackets: false, + }; + let del_to_next_subword_end_ignore_newlines = DeleteToNextSubwordEnd { + ignore_newlines: true, + ignore_brackets: false, + }; + + _ = editor.update(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 0) + ]) + }); + // Delete "\n" (empty line) + editor.delete_to_next_subword_end(&del_to_next_subword_end, window, cx); + assert_eq!(editor.buffer.read(cx).read(cx).text(), "fooBar\n bazQux"); + // Delete "foo" (subword boundary) + editor.delete_to_next_subword_end(&del_to_next_subword_end, window, cx); + assert_eq!(editor.buffer.read(cx).read(cx).text(), "Bar\n bazQux"); + // Delete "Bar" + editor.delete_to_next_subword_end(&del_to_next_subword_end, window, cx); + assert_eq!(editor.buffer.read(cx).read(cx).text(), "\n bazQux"); + // Delete "\n " (newline + leading whitespace) + editor.delete_to_next_subword_end(&del_to_next_subword_end, window, cx); + assert_eq!(editor.buffer.read(cx).read(cx).text(), "bazQux"); + // Delete "baz" (subword boundary) + editor.delete_to_next_subword_end(&del_to_next_subword_end, window, cx); + assert_eq!(editor.buffer.read(cx).read(cx).text(), "Qux"); + // With ignore_newlines, delete "Qux" + editor.delete_to_next_subword_end(&del_to_next_subword_end_ignore_newlines, window, cx); + assert_eq!(editor.buffer.read(cx).read(cx).text(), ""); + }); +} + +#[gpui::test] +fn test_newline(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let editor = cx.add_window(|window, cx| { + let buffer = MultiBuffer::build_simple("aaaa\n bbbb\n", cx); + build_editor(buffer, window, cx) + }); + + _ = editor.update(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(0), 2)..DisplayPoint::new(DisplayRow(0), 2), + DisplayPoint::new(DisplayRow(1), 2)..DisplayPoint::new(DisplayRow(1), 2), + DisplayPoint::new(DisplayRow(1), 6)..DisplayPoint::new(DisplayRow(1), 6), + ]) + }); + + editor.newline(&Newline, window, cx); + assert_eq!(editor.text(cx), "aa\naa\n \n bb\n bb\n"); + }); +} + +#[gpui::test] +fn test_newline_trailing_whitespace(cx: &mut TestAppContext) { + init_test(cx, |settings| { + settings.defaults.auto_indent = Some(settings::AutoIndentMode::PreserveIndent); + }); + + let buffer = cx.update(|cx| MultiBuffer::build_simple(" hello\n world\n", cx)); + let editor = cx.add_window(|window, cx| build_editor(buffer.clone(), window, cx)); + + editor + .update(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(0), 9)..DisplayPoint::new(DisplayRow(0), 9) + ]) + }); + + editor.newline(&Newline, window, cx); + assert_eq!(editor.text(cx), " hello\n \n world\n"); + + editor.newline(&Newline, window, cx); + assert_eq!(editor.text(cx), " hello\n\n \n world\n"); + }) + .unwrap(); + + buffer.update(cx, |buffer, cx| { + let start = MultiBufferOffset(0); + let end = buffer.len(cx); + buffer.edit([(start..end, " hello\n world\n")], None, cx); + }); + + editor + .update(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(0), 7)..DisplayPoint::new(DisplayRow(0), 7) + ]) + }); + + editor.newline(&Newline, window, cx); + assert_eq!(editor.text(cx), " hel\n lo\n world\n"); + + editor.newline(&Newline, window, cx); + assert_eq!(editor.text(cx), " hel\n\n lo\n world\n"); + }) + .unwrap(); + + update_test_language_settings(cx, &|settings| { + settings.defaults.tab_size = NonZeroU32::new(4); + settings.defaults.hard_tabs = Some(true); + }); + + buffer.update(cx, |buffer, cx| { + let start = MultiBufferOffset(0); + let end = buffer.len(cx); + buffer.edit([(start..end, "\thello\n\tworld\n")], None, cx); + }); + + editor + .update(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(0), 9)..DisplayPoint::new(DisplayRow(0), 9) + ]) + }); + + editor.newline(&Newline, window, cx); + assert_eq!(editor.text(cx), "\thello\n\t\n\tworld\n"); + + editor.newline(&Newline, window, cx); + assert_eq!(editor.text(cx), "\thello\n\n\t\n\tworld\n"); + }) + .unwrap(); +} + +#[gpui::test] +async fn test_newline_yaml(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + let yaml_language = languages::language("yaml", tree_sitter_yaml::LANGUAGE.into()); + cx.update_buffer(|buffer, cx| buffer.set_language(Some(yaml_language), cx)); + + // Object (between 2 fields) + cx.set_state(indoc! {" + test:ˇ + hello: bye"}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.assert_editor_state(indoc! {" + test: + ˇ + hello: bye"}); + + // Object (first and single line) + cx.set_state(indoc! {" + test:ˇ"}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.assert_editor_state(indoc! {" + test: + ˇ"}); + + // Array with objects (after first element) + cx.set_state(indoc! {" + test: + - foo: barˇ"}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.assert_editor_state(indoc! {" + test: + - foo: bar + ˇ"}); + + // Array with objects and comment + cx.set_state(indoc! {" + test: + - foo: bar + - bar: # testˇ"}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.assert_editor_state(indoc! {" + test: + - foo: bar + - bar: # test + ˇ"}); + + // Array with objects (after second element) + cx.set_state(indoc! {" + test: + - foo: bar + - bar: fooˇ"}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.assert_editor_state(indoc! {" + test: + - foo: bar + - bar: foo + ˇ"}); + + // Array with strings (after first element) + cx.set_state(indoc! {" + test: + - fooˇ"}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.assert_editor_state(indoc! {" + test: + - foo + ˇ"}); +} + +#[gpui::test] +fn test_newline_with_old_selections(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let editor = cx.add_window(|window, cx| { + let buffer = MultiBuffer::build_simple( + " + a + b( + X + ) + c( + X + ) + " + .unindent() + .as_str(), + cx, + ); + let mut editor = build_editor(buffer, window, cx); + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_ranges([ + Point::new(2, 4)..Point::new(2, 5), + Point::new(5, 4)..Point::new(5, 5), + ]) + }); + editor + }); + + _ = editor.update(cx, |editor, window, cx| { + // Edit the buffer directly, deleting ranges surrounding the editor's selections + editor.buffer.update(cx, |buffer, cx| { + buffer.edit( + [ + (Point::new(1, 2)..Point::new(3, 0), ""), + (Point::new(4, 2)..Point::new(6, 0), ""), + ], + None, + cx, + ); + assert_eq!( + buffer.read(cx).text(), + " + a + b() + c() + " + .unindent() + ); + }); + assert_eq!( + editor.selections.ranges(&editor.display_snapshot(cx)), + &[ + Point::new(1, 2)..Point::new(1, 2), + Point::new(2, 2)..Point::new(2, 2), + ], + ); + + editor.newline(&Newline, window, cx); + assert_eq!( + editor.text(cx), + " + a + b( + ) + c( + ) + " + .unindent() + ); + + // The selections are moved after the inserted newlines + assert_eq!( + editor.selections.ranges(&editor.display_snapshot(cx)), + &[ + Point::new(2, 0)..Point::new(2, 0), + Point::new(4, 0)..Point::new(4, 0), + ], + ); + }); +} + +#[gpui::test] +async fn test_newline_above(cx: &mut TestAppContext) { + init_test(cx, |settings| { + settings.defaults.tab_size = NonZeroU32::new(4) + }); + + let language = Arc::new( + Language::new( + LanguageConfig::default(), + Some(tree_sitter_rust::LANGUAGE.into()), + ) + .with_indents_query(r#"(_ "(" ")" @end) @indent"#) + .unwrap(), + ); + + let mut cx = EditorTestContext::new(cx).await; + cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); + cx.set_state(indoc! {" + const a: ˇA = ( + (ˇ + «const_functionˇ»(ˇ), + so«mˇ»et«hˇ»ing_ˇelse,ˇ + )ˇ + ˇ);ˇ + "}); + + cx.update_editor(|e, window, cx| e.newline_above(&NewlineAbove, window, cx)); + cx.assert_editor_state(indoc! {" + ˇ + const a: A = ( + ˇ + ( + ˇ + ˇ + const_function(), + ˇ + ˇ + ˇ + ˇ + something_else, + ˇ + ) + ˇ + ˇ + ); + "}); +} + +#[gpui::test] +async fn test_newline_below(cx: &mut TestAppContext) { + init_test(cx, |settings| { + settings.defaults.tab_size = NonZeroU32::new(4) + }); + + let language = Arc::new( + Language::new( + LanguageConfig::default(), + Some(tree_sitter_rust::LANGUAGE.into()), + ) + .with_indents_query(r#"(_ "(" ")" @end) @indent"#) + .unwrap(), + ); + + let mut cx = EditorTestContext::new(cx).await; + cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); + cx.set_state(indoc! {" + const a: ˇA = ( + (ˇ + «const_functionˇ»(ˇ), + so«mˇ»et«hˇ»ing_ˇelse,ˇ + )ˇ + ˇ);ˇ + "}); + + cx.update_editor(|e, window, cx| e.newline_below(&NewlineBelow, window, cx)); + cx.assert_editor_state(indoc! {" + const a: A = ( + ˇ + ( + ˇ + const_function(), + ˇ + ˇ + something_else, + ˇ + ˇ + ˇ + ˇ + ) + ˇ + ); + ˇ + ˇ + "}); +} + +#[gpui::test] +fn test_newline_respects_read_only(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let editor = cx.add_window(|window, cx| { + let buffer = MultiBuffer::build_simple("aaaa\nbbbb\n", cx); + build_editor(buffer, window, cx) + }); + + _ = editor.update(cx, |editor, window, cx| { + editor.set_read_only(true); + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(0), 2)..DisplayPoint::new(DisplayRow(0), 2) + ]) + }); + + editor.newline(&Newline, window, cx); + assert_eq!( + editor.text(cx), + "aaaa\nbbbb\n", + "newline should not modify a read-only editor" + ); + + editor.newline_above(&NewlineAbove, window, cx); + assert_eq!( + editor.text(cx), + "aaaa\nbbbb\n", + "newline_above should not modify a read-only editor" + ); + + editor.newline_below(&NewlineBelow, window, cx); + assert_eq!( + editor.text(cx), + "aaaa\nbbbb\n", + "newline_below should not modify a read-only editor" + ); + }); +} + +#[gpui::test] +async fn test_newline_below_with_cursor_on_deleted_hunk(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + + cx.set_state("aaa\nbbb\ncˇcc"); + cx.set_head_text("aaa\nXXX\nbbb\nccc"); + cx.run_until_parked(); + cx.update_editor(|editor, window, cx| { + editor.expand_all_diff_hunks(&Default::default(), window, cx); + }); + cx.run_until_parked(); + + cx.update_editor(|editor, window, cx| { + editor.change_selections(Default::default(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(1), 0)..DisplayPoint::new(DisplayRow(1), 0), + DisplayPoint::new(DisplayRow(3), 3)..DisplayPoint::new(DisplayRow(3), 3), + ]); + }); + }); + + cx.update_editor(|editor, window, cx| { + editor.newline_below(&NewlineBelow, window, cx); + }); + cx.run_until_parked(); + + assert_eq!(cx.buffer(|buffer, _| buffer.text()), "aaa\nbbb\nccc\n"); + + let cursors = cx.update_editor(|editor, window, cx| { + let display_snapshot = editor.snapshot(window, cx).display_snapshot; + editor + .selections + .all_display(&display_snapshot) + .iter() + .map(|selection| selection.head()) + .collect::>() + }); + assert_eq!( + cursors, + vec![ + DisplayPoint::new(DisplayRow(1), 0), + DisplayPoint::new(DisplayRow(4), 0), + ], + ); +} + +#[gpui::test] +fn test_expand_excerpts_with_selection_ending_at_excerpt_boundary(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + for (direction, expected) in [ + (ExpandExcerptDirection::Up, "a0\na1\na2\na3\nb1\nb2\nb3"), + (ExpandExcerptDirection::Down, "a1\na2\na3\na4\nb1\nb2\nb3"), + ( + ExpandExcerptDirection::UpAndDown, + "a0\na1\na2\na3\na4\nb1\nb2\nb3", + ), + ] { + let mut cx = EditorTestContext::new_multibuffer( + cx, + ["a0\n«a1\na2\na3»\na4", "b0\n«b1\nb2\nb3»\nb4"], + ); + cx.update_editor(|editor, window, cx| { + editor.change_selections(Default::default(), window, cx, |selections| { + selections.select_ranges([Point::zero()..Point::new(3, 0)]); + }); + match direction { + ExpandExcerptDirection::Up => { + editor.expand_excerpts_up(&ExpandExcerptsUp { lines: 1 }, window, cx) + } + ExpandExcerptDirection::Down => { + editor.expand_excerpts_down(&ExpandExcerptsDown { lines: 1 }, window, cx) + } + ExpandExcerptDirection::UpAndDown => { + editor.expand_excerpts(&ExpandExcerpts { lines: 1 }, window, cx) + } + } + }); + assert_eq!(cx.buffer_text(), expected, "{direction:?}"); + } +} + +#[gpui::test] +fn test_expand_excerpts_with_trailing_empty_excerpt(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + for (direction, expected) in [ + (ExpandExcerptDirection::Up, "aaa\none\n"), + (ExpandExcerptDirection::Down, "aaa\n"), + (ExpandExcerptDirection::UpAndDown, "aaa\none\n"), + ] { + for select_all in [false, true] { + let mut cx = EditorTestContext::new_multibuffer(cx, ["«aaa»", "zero\none\n«»"]); + cx.update_editor(|editor, window, cx| { + if select_all { + editor.select_all(&SelectAll, window, cx); + } else { + editor.move_to_end(&MoveToEnd, window, cx); + } + match direction { + ExpandExcerptDirection::Up => { + editor.expand_excerpts_up(&ExpandExcerptsUp { lines: 1 }, window, cx) + } + ExpandExcerptDirection::Down => { + editor.expand_excerpts_down(&ExpandExcerptsDown { lines: 1 }, window, cx) + } + ExpandExcerptDirection::UpAndDown => { + editor.expand_excerpts(&ExpandExcerpts { lines: 1 }, window, cx) + } + } + }); + assert_eq!( + cx.buffer_text(), + expected, + "{direction:?}, select_all={select_all}" + ); + } + } +} + +#[gpui::test] +async fn test_expand_excerpts_with_selection_on_deleted_hunk(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + + cx.set_state("zero\none\ntwo\nthree\nfour\nfive\nsix\nsevenˇ"); + let buffer = cx.multibuffer(|multi_buffer, _| multi_buffer.as_singleton().unwrap()); + cx.update_multibuffer(|multi_buffer, cx| { + let path_key = PathKey::for_buffer(&buffer, cx); + multi_buffer.set_excerpts_for_path( + path_key, + buffer, + [Point::new(2, 0)..Point::new(5, 4)], + 0, + cx, + ); + }); + cx.set_head_text("zero\none\ntwo\nthree\ndeleted\nfour\nfive\nsix\nseven"); + cx.run_until_parked(); + cx.update_editor(|editor, window, cx| { + editor.expand_all_diff_hunks(&Default::default(), window, cx); + }); + cx.run_until_parked(); + assert_eq!(cx.buffer_text(), "two\nthree\ndeleted\nfour\nfive"); + + cx.update_editor(|editor, window, cx| { + editor.change_selections(Default::default(), window, cx, |selections| { + let cursor = DisplayPoint::new(DisplayRow(2), 0); + selections.select_display_ranges([cursor..cursor]); + }); + }); + assert!(cx.update_editor(|editor, _, _| { + editor + .selections + .newest_anchor() + .head() + .diff_base_anchor() + .is_some() + })); + cx.update_editor(|editor, window, cx| { + editor.expand_excerpts(&ExpandExcerpts { lines: 1 }, window, cx); + }); + assert_eq!( + cx.buffer_text(), + "one\ntwo\nthree\ndeleted\nfour\nfive\nsix" + ); + + cx.update_editor(|editor, window, cx| { + editor.change_selections(Default::default(), window, cx, |selections| { + selections.select_display_ranges([ + DisplayPoint::new(DisplayRow(3), 1)..DisplayPoint::new(DisplayRow(3), 4) + ]); + }); + }); + cx.update_editor(|editor, window, cx| { + editor.expand_excerpts(&ExpandExcerpts { lines: 1 }, window, cx); + }); + assert_eq!( + cx.buffer_text(), + "zero\none\ntwo\nthree\ndeleted\nfour\nfive\nsix\nseven" + ); +} + +#[gpui::test] +async fn test_expand_excerpts_with_selection_in_removed_path(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let buffer_a = cx.new(|cx| Buffer::local("a0\na1\na2\na3\na4", cx)); + let buffer_b = cx.new(|cx| Buffer::local("b0\nb1\nb2\nb3\nb4", cx)); + let multibuffer = cx.new(|cx| { + let mut multibuffer = MultiBuffer::new(ReadWrite); + multibuffer.set_excerpts_for_path( + PathKey::sorted(0), + buffer_a, + [Point::new(1, 0)..Point::new(3, 2)], + 0, + cx, + ); + multibuffer.set_excerpts_for_path( + PathKey::sorted(1), + buffer_b.clone(), + [Point::new(1, 0)..Point::new(3, 2)], + 0, + cx, + ); + multibuffer + }); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [path!("/").as_ref()], cx).await; + let (editor, cx) = cx.add_window_view(|window, cx| { + build_editor_with_project(project, multibuffer.clone(), window, cx) + }); + // The second path's excerpt begins with a deleted hunk. + editor.update_in(cx, |editor, window, cx| { + let diff = cx.new(|cx| { + BufferDiff::new_with_base_text( + "b0\ndeleted\nb1\nb2\nb3\nb4", + &buffer_b.read(cx).text_snapshot(), + cx, + ) + }); + editor + .buffer + .update(cx, |buffer, cx| buffer.add_diff(diff, cx)); + editor.expand_all_diff_hunks(&Default::default(), window, cx); + }); + cx.run_until_parked(); + editor.update(cx, |editor, cx| { + assert_eq!(editor.text(cx), "a1\na2\na3\ndeleted\nb1\nb2\nb3"); + }); + + editor.update_in(cx, |editor, window, cx| { + editor.change_selections(Default::default(), window, cx, |selections| { + selections.select_ranges([Point::new(1, 0)..Point::new(1, 0)]); + }); + }); + multibuffer.update(cx, |multibuffer, cx| { + multibuffer.remove_excerpts(PathKey::sorted(0), cx); + }); + cx.run_until_parked(); + editor.update(cx, |editor, cx| { + assert_eq!(editor.text(cx), "deleted\nb1\nb2\nb3"); + }); + + editor.update_in(cx, |editor, window, cx| { + editor.expand_excerpts(&ExpandExcerpts { lines: 1 }, window, cx); + }); + editor.update(cx, |editor, cx| { + assert_eq!(editor.text(cx), "b0\ndeleted\nb1\nb2\nb3\nb4"); + }); +} + +#[gpui::test] +fn test_newline_below_multibuffer(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let buffer_1 = cx.new(|cx| Buffer::local("aaa\nbbb\nccc", cx)); + let buffer_2 = cx.new(|cx| Buffer::local("ddd\neee\nfff", cx)); + let multibuffer = cx.new(|cx| { + let mut multibuffer = MultiBuffer::new(ReadWrite); + multibuffer.set_excerpts_for_path( + PathKey::sorted(0), + buffer_1.clone(), + [Point::new(0, 0)..Point::new(2, 3)], + 0, + cx, + ); + multibuffer.set_excerpts_for_path( + PathKey::sorted(1), + buffer_2.clone(), + [Point::new(0, 0)..Point::new(2, 3)], + 0, + cx, + ); + multibuffer + }); + + cx.add_window(|window, cx| { + let mut editor = build_editor(multibuffer, window, cx); + + assert_eq!( + editor.text(cx), + indoc! {" + aaa + bbb + ccc + ddd + eee + fff"} + ); + + // Cursor on the last line of the first excerpt. + // The newline should be inserted within the first excerpt (buffer_1), + // not in the second excerpt (buffer_2). + select_ranges( + &mut editor, + indoc! {" + aaa + bbb + cˇcc + ddd + eee + fff"}, + window, + cx, + ); + editor.newline_below(&NewlineBelow, window, cx); + assert_text_with_selections( + &mut editor, + indoc! {" + aaa + bbb + ccc + ˇ + ddd + eee + fff"}, + cx, + ); + buffer_1.read_with(cx, |buffer, _| { + assert_eq!(buffer.text(), "aaa\nbbb\nccc\n"); + }); + buffer_2.read_with(cx, |buffer, _| { + assert_eq!(buffer.text(), "ddd\neee\nfff"); + }); + + editor + }); +} + +#[gpui::test] +fn test_newline_below_multibuffer_middle_of_excerpt(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let buffer_1 = cx.new(|cx| Buffer::local("aaa\nbbb\nccc", cx)); + let buffer_2 = cx.new(|cx| Buffer::local("ddd\neee\nfff", cx)); + let multibuffer = cx.new(|cx| { + let mut multibuffer = MultiBuffer::new(ReadWrite); + multibuffer.set_excerpts_for_path( + PathKey::sorted(0), + buffer_1.clone(), + [Point::new(0, 0)..Point::new(2, 3)], + 0, + cx, + ); + multibuffer.set_excerpts_for_path( + PathKey::sorted(1), + buffer_2.clone(), + [Point::new(0, 0)..Point::new(2, 3)], + 0, + cx, + ); + multibuffer + }); + + cx.add_window(|window, cx| { + let mut editor = build_editor(multibuffer, window, cx); + + // Cursor in the middle of the first excerpt. + select_ranges( + &mut editor, + indoc! {" + aˇaa + bbb + ccc + ddd + eee + fff"}, + window, + cx, + ); + editor.newline_below(&NewlineBelow, window, cx); + assert_text_with_selections( + &mut editor, + indoc! {" + aaa + ˇ + bbb + ccc + ddd + eee + fff"}, + cx, + ); + buffer_1.read_with(cx, |buffer, _| { + assert_eq!(buffer.text(), "aaa\n\nbbb\nccc"); + }); + buffer_2.read_with(cx, |buffer, _| { + assert_eq!(buffer.text(), "ddd\neee\nfff"); + }); + + editor + }); +} + +#[gpui::test] +fn test_newline_below_multibuffer_last_line_of_last_excerpt(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let buffer_1 = cx.new(|cx| Buffer::local("aaa\nbbb\nccc", cx)); + let buffer_2 = cx.new(|cx| Buffer::local("ddd\neee\nfff", cx)); + let multibuffer = cx.new(|cx| { + let mut multibuffer = MultiBuffer::new(ReadWrite); + multibuffer.set_excerpts_for_path( + PathKey::sorted(0), + buffer_1.clone(), + [Point::new(0, 0)..Point::new(2, 3)], + 0, + cx, + ); + multibuffer.set_excerpts_for_path( + PathKey::sorted(1), + buffer_2.clone(), + [Point::new(0, 0)..Point::new(2, 3)], + 0, + cx, + ); + multibuffer + }); + + cx.add_window(|window, cx| { + let mut editor = build_editor(multibuffer, window, cx); + + // Cursor on the last line of the last excerpt. + select_ranges( + &mut editor, + indoc! {" + aaa + bbb + ccc + ddd + eee + fˇff"}, + window, + cx, + ); + editor.newline_below(&NewlineBelow, window, cx); + assert_text_with_selections( + &mut editor, + indoc! {" + aaa + bbb + ccc + ddd + eee + fff + ˇ"}, + cx, + ); + buffer_1.read_with(cx, |buffer, _| { + assert_eq!(buffer.text(), "aaa\nbbb\nccc"); + }); + buffer_2.read_with(cx, |buffer, _| { + assert_eq!(buffer.text(), "ddd\neee\nfff\n"); + }); + + editor + }); +} + +#[gpui::test] +fn test_newline_below_multibuffer_multiple_cursors(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let buffer_1 = cx.new(|cx| Buffer::local("aaa\nbbb\nccc", cx)); + let buffer_2 = cx.new(|cx| Buffer::local("ddd\neee\nfff", cx)); + let multibuffer = cx.new(|cx| { + let mut multibuffer = MultiBuffer::new(ReadWrite); + multibuffer.set_excerpts_for_path( + PathKey::sorted(0), + buffer_1.clone(), + [Point::new(0, 0)..Point::new(2, 3)], + 0, + cx, + ); + multibuffer.set_excerpts_for_path( + PathKey::sorted(1), + buffer_2.clone(), + [Point::new(0, 0)..Point::new(2, 3)], + 0, + cx, + ); + multibuffer + }); + + cx.add_window(|window, cx| { + let mut editor = build_editor(multibuffer, window, cx); + + // Cursors on the last line of the first excerpt and the first line + // of the second excerpt. Each newline should go into its respective buffer. + select_ranges( + &mut editor, + indoc! {" + aaa + bbb + cˇcc + dˇdd + eee + fff"}, + window, + cx, + ); + editor.newline_below(&NewlineBelow, window, cx); + assert_text_with_selections( + &mut editor, + indoc! {" + aaa + bbb + ccc + ˇ + ddd + ˇ + eee + fff"}, + cx, + ); + buffer_1.read_with(cx, |buffer, _| { + assert_eq!(buffer.text(), "aaa\nbbb\nccc\n"); + }); + buffer_2.read_with(cx, |buffer, _| { + assert_eq!(buffer.text(), "ddd\n\neee\nfff"); + }); + + editor + }); +} + +#[gpui::test] +async fn test_newline_comments(cx: &mut TestAppContext) { + init_test(cx, |settings| { + settings.defaults.tab_size = NonZeroU32::new(4) + }); + + let language = Arc::new(Language::new( + LanguageConfig { + line_comments: vec!["// ".into()], + ..LanguageConfig::default() + }, + None, + )); + { + let mut cx = EditorTestContext::new(cx).await; + cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); + cx.set_state(indoc! {" + // Fooˇ + "}); + + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.assert_editor_state(indoc! {" + // Foo + // ˇ + "}); + // Ensure that we add comment prefix when existing line contains space + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.assert_editor_state( + indoc! {" + // Foo + //s + // ˇ + "} + .replace("s", " ") // s is used as space placeholder to prevent format on save + .as_str(), + ); + // Ensure that we add comment prefix when existing line does not contain space + cx.set_state(indoc! {" + // Foo + //ˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.assert_editor_state(indoc! {" + // Foo + // + // ˇ + "}); + // Ensure that if cursor is before the comment start, we do not actually insert a comment prefix. + cx.set_state(indoc! {" + ˇ// Foo + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.assert_editor_state(indoc! {" + + ˇ// Foo + "}); + } + // Ensure that comment continuations can be disabled. + update_test_language_settings(cx, &|settings| { + settings.defaults.extend_comment_on_newline = Some(false); + }); + let mut cx = EditorTestContext::new(cx).await; + cx.set_state(indoc! {" + // Fooˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.assert_editor_state(indoc! {" + // Foo + ˇ + "}); +} + +#[gpui::test] +async fn test_newline_comments_with_multiple_delimiters(cx: &mut TestAppContext) { + init_test(cx, |settings| { + settings.defaults.tab_size = NonZeroU32::new(4) + }); + + let language = Arc::new(Language::new( + LanguageConfig { + line_comments: vec!["// ".into(), "/// ".into()], + ..LanguageConfig::default() + }, + None, + )); + { + let mut cx = EditorTestContext::new(cx).await; + cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); + cx.set_state(indoc! {" + //ˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.assert_editor_state(indoc! {" + // + // ˇ + "}); + + cx.set_state(indoc! {" + ///ˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.assert_editor_state(indoc! {" + /// + /// ˇ + "}); + } +} + +#[gpui::test] +async fn test_newline_comments_with_brackets(cx: &mut TestAppContext) { + init_test(cx, |settings| { + settings.defaults.tab_size = NonZeroU32::new(4) + }); + let language = Arc::new(Language::new( + LanguageConfig { + line_comments: vec!["// ".into()], + brackets: BracketPairConfig { + pairs: vec![BracketPair { + start: "(".to_string(), + end: ")".to_string(), + close: false, + surround: false, + newline: true, + }], + ..BracketPairConfig::default() + }, + ..LanguageConfig::default() + }, + None, + )); + + { + let mut cx = EditorTestContext::new(cx).await; + cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); + cx.set_state(indoc! {" + // (ˇ) + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.assert_editor_state(indoc! {" + // ( + // ˇ) + "}) + } +} + +#[gpui::test] +async fn test_newline_comments_repl_separators(cx: &mut TestAppContext) { + init_test(cx, |settings| { + settings.defaults.tab_size = NonZeroU32::new(4) + }); + let language = Arc::new(Language::new( + LanguageConfig { + line_comments: vec!["# ".into()], + ..LanguageConfig::default() + }, + None, + )); + + { + let mut cx = EditorTestContext::new(cx).await; + cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); + cx.set_state(indoc! {" + # %%ˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.assert_editor_state(indoc! {" + # %% + ˇ + "}); + + cx.set_state(indoc! {" + # %%%%%ˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.assert_editor_state(indoc! {" + # %%%%% + ˇ + "}); + + cx.set_state(indoc! {" + # %ˇ% + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.assert_editor_state(indoc! {" + # % + # ˇ% + "}); + } +} + +#[gpui::test] +async fn test_newline_documentation_comments(cx: &mut TestAppContext) { + init_test(cx, |settings| { + settings.defaults.tab_size = NonZeroU32::new(4) + }); + + let language = Arc::new( + Language::new( + LanguageConfig { + documentation_comment: Some(language::BlockCommentConfig { + start: "/**".into(), + end: "*/".into(), + prefix: "* ".into(), + tab_size: 1, + }), + + ..LanguageConfig::default() + }, + Some(tree_sitter_rust::LANGUAGE.into()), + ) + .with_override_query("[(line_comment)(block_comment)] @comment.inclusive") + .unwrap(), + ); + + { + let mut cx = EditorTestContext::new(cx).await; + cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); + cx.set_state(indoc! {" + /**ˇ + "}); + + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.assert_editor_state(indoc! {" + /** + * ˇ + "}); + // Ensure that if cursor is before the comment start, + // we do not actually insert a comment prefix. + cx.set_state(indoc! {" + ˇ/** + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.assert_editor_state(indoc! {" + + ˇ/** + "}); + // Ensure that if cursor is between it doesn't add comment prefix. + cx.set_state(indoc! {" + /*ˇ* + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.assert_editor_state(indoc! {" + /* + ˇ* + "}); + // Ensure that if suffix exists on same line after cursor it adds new line. + cx.set_state(indoc! {" + /**ˇ*/ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.assert_editor_state(indoc! {" + /** + * ˇ + */ + "}); + // Ensure that if suffix exists on same line after cursor with space it adds new line. + cx.set_state(indoc! {" + /**ˇ */ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.assert_editor_state(indoc! {" + /** + * ˇ + */ + "}); + // Ensure that if suffix exists on same line after cursor with space it adds new line. + cx.set_state(indoc! {" + /** ˇ*/ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.assert_editor_state( + indoc! {" + /**s + * ˇ + */ + "} + .replace("s", " ") // s is used as space placeholder to prevent format on save + .as_str(), + ); + // Ensure that delimiter space is preserved when newline on already + // spaced delimiter. + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.assert_editor_state( + indoc! {" + /**s + *s + * ˇ + */ + "} + .replace("s", " ") // s is used as space placeholder to prevent format on save + .as_str(), + ); + // Ensure that delimiter space is preserved when space is not + // on existing delimiter. + cx.set_state(indoc! {" + /** + *ˇ + */ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.assert_editor_state(indoc! {" + /** + * + * ˇ + */ + "}); + // Ensure that if suffix exists on same line after cursor it + // doesn't add extra new line if prefix is not on same line. + cx.set_state(indoc! {" + /** + ˇ*/ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.assert_editor_state(indoc! {" + /** + + ˇ*/ + "}); + // Ensure that it detects suffix after existing prefix. + cx.set_state(indoc! {" + /**ˇ/ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.assert_editor_state(indoc! {" + /** + ˇ/ + "}); + // Ensure that if suffix exists on same line before + // cursor it does not add comment prefix. + cx.set_state(indoc! {" + /** */ˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.assert_editor_state(indoc! {" + /** */ + ˇ + "}); + // Ensure that if suffix exists on same line before + // cursor it does not add comment prefix. + cx.set_state(indoc! {" + /** + * + */ˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.assert_editor_state(indoc! {" + /** + * + */ + ˇ + "}); + + cx.set_state("fn test() {\n /**\n *\n */ˇ\n}"); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.assert_editor_state("fn test() {\n /**\n *\n */\n ˇ\n}"); + + // Ensure that inline comment followed by code + // doesn't add comment prefix on newline + cx.set_state(indoc! {" + /** */ textˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.assert_editor_state(indoc! {" + /** */ text + ˇ + "}); + + // Ensure that text after comment end tag + // doesn't add comment prefix on newline + cx.set_state(indoc! {" + /** + * + */ˇtext + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.assert_editor_state(indoc! {" + /** + * + */ + ˇtext + "}); + + // Ensure if not comment block it doesn't + // add comment prefix on newline + cx.set_state(indoc! {" + * textˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.assert_editor_state(indoc! {" + * text + ˇ + "}); + } + // Ensure that comment continuations can be disabled. + update_test_language_settings(cx, &|settings| { + settings.defaults.extend_comment_on_newline = Some(false); + }); + let mut cx = EditorTestContext::new(cx).await; + cx.set_state(indoc! {" + /**ˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.assert_editor_state(indoc! {" + /** + ˇ + "}); +} + +#[gpui::test] +async fn test_newline_closing_comment_indent_across_languages(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + + let go_language = languages::language("go", tree_sitter_go::LANGUAGE.into()); + cx.update_buffer(|buffer, cx| buffer.set_language(Some(go_language), cx)); + cx.set_state("func test() {\n\t/**\n\t * doc\n\t */ˇ\n}"); + cx.update_editor(|editor, window, cx| editor.newline(&Newline, window, cx)); + cx.assert_editor_state("func test() {\n\t/**\n\t * doc\n\t */\n\tˇ\n}"); + + let typescript_language = languages::language( + "typescript", + tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(), + ); + cx.update_buffer(|buffer, cx| buffer.set_language(Some(typescript_language), cx)); + cx.set_state("function test() {\n /**\n * doc\n */ˇ\n}"); + cx.update_editor(|editor, window, cx| editor.newline(&Newline, window, cx)); + cx.assert_editor_state("function test() {\n /**\n * doc\n */\n ˇ\n}"); + + let python_language = languages::language("python", tree_sitter_python::LANGUAGE.into()); + cx.update_buffer(|buffer, cx| buffer.set_language(Some(python_language), cx)); + cx.set_state("def test():\n \"\"\"doc\n \"\"\"ˇ\n"); + cx.update_editor(|editor, window, cx| editor.newline(&Newline, window, cx)); + cx.assert_editor_state("def test():\n \"\"\"doc\n \"\"\"\n ˇ\n"); +} + +#[gpui::test] +async fn test_newline_after_closing_delimiter_lookalike_in_string(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + cx.update_buffer(|buffer, cx| { + buffer.set_language( + Some(languages::language("go", tree_sitter_go::LANGUAGE.into())), + cx, + ) + }); + + // A line inside a string literal that happens to look like a block comment's + // closing delimiter does not close anything, so it keeps its own indentation. + cx.set_state("func test() {\n\tvar s = `\n*/ˇ\n`\n}"); + cx.update_editor(|editor, window, cx| editor.newline(&Newline, window, cx)); + cx.assert_editor_state("func test() {\n\tvar s = `\n*/\nˇ\n`\n}"); +} + +#[gpui::test] +async fn test_newline_twice_inside_block(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + cx.update_buffer(|buffer, cx| { + buffer.set_language( + Some(languages::language("go", tree_sitter_go::LANGUAGE.into())), + cx, + ) + }); + + cx.set_state("func test() {ˇ}"); + cx.update_editor(|editor, window, cx| editor.newline(&Newline, window, cx)); + cx.assert_editor_state("func test() {\n ˇ\n}"); + // The blank line the cursor sits on holds nothing but auto-indent + // whitespace, but the line that replaces it still belongs inside the block. + cx.update_editor(|editor, window, cx| editor.newline(&Newline, window, cx)); + cx.assert_editor_state("func test() {\n\n ˇ\n}"); +} + +#[gpui::test] +async fn test_newline_comments_with_block_comment(cx: &mut TestAppContext) { + init_test(cx, |settings| { + settings.defaults.tab_size = NonZeroU32::new(4) + }); + + let lua_language = Arc::new(Language::new( + LanguageConfig { + line_comments: vec!["--".into()], + block_comment: Some(language::BlockCommentConfig { + start: "--[[".into(), + prefix: "".into(), + end: "]]".into(), + tab_size: 0, + }), + ..LanguageConfig::default() + }, + None, + )); + + let mut cx = EditorTestContext::new(cx).await; + cx.update_buffer(|buffer, cx| buffer.set_language(Some(lua_language), cx)); + + // Line with line comment should extend + cx.set_state(indoc! {" + --ˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.assert_editor_state(indoc! {" + -- + --ˇ + "}); + + // Line with block comment that matches line comment should not extend + cx.set_state(indoc! {" + --[[ˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.assert_editor_state(indoc! {" + --[[ + ˇ + "}); +} + +#[gpui::test] +fn test_insert_with_old_selections(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let editor = cx.add_window(|window, cx| { + let buffer = MultiBuffer::build_simple("a( X ), b( Y ), c( Z )", cx); + let mut editor = build_editor(buffer, window, cx); + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_ranges([ + MultiBufferOffset(3)..MultiBufferOffset(4), + MultiBufferOffset(11)..MultiBufferOffset(12), + MultiBufferOffset(19)..MultiBufferOffset(20), + ]) + }); + editor + }); + + _ = editor.update(cx, |editor, window, cx| { + // Edit the buffer directly, deleting ranges surrounding the editor's selections + editor.buffer.update(cx, |buffer, cx| { + buffer.edit( + [ + (MultiBufferOffset(2)..MultiBufferOffset(5), ""), + (MultiBufferOffset(10)..MultiBufferOffset(13), ""), + (MultiBufferOffset(18)..MultiBufferOffset(21), ""), + ], + None, + cx, + ); + assert_eq!(buffer.read(cx).text(), "a(), b(), c()".unindent()); + }); + assert_eq!( + editor.selections.ranges(&editor.display_snapshot(cx)), + &[ + MultiBufferOffset(2)..MultiBufferOffset(2), + MultiBufferOffset(7)..MultiBufferOffset(7), + MultiBufferOffset(12)..MultiBufferOffset(12) + ], + ); + + editor.insert("Z", window, cx); + assert_eq!(editor.text(cx), "a(Z), b(Z), c(Z)"); + + // The selections are moved after the inserted characters + assert_eq!( + editor.selections.ranges(&editor.display_snapshot(cx)), + &[ + MultiBufferOffset(3)..MultiBufferOffset(3), + MultiBufferOffset(9)..MultiBufferOffset(9), + MultiBufferOffset(15)..MultiBufferOffset(15) + ], + ); + }); +} + +#[gpui::test] +async fn test_tab(cx: &mut TestAppContext) { + init_test(cx, |settings| { + settings.defaults.tab_size = NonZeroU32::new(3) + }); + + let mut cx = EditorTestContext::new(cx).await; + cx.set_state(indoc! {" + ˇabˇc + ˇ🏀ˇ🏀ˇefg + dˇ + "}); + cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); + cx.assert_editor_state(indoc! {" + ˇab ˇc + ˇ🏀 ˇ🏀 ˇefg + d ˇ + "}); + + cx.set_state(indoc! {" + a + «🏀ˇ»🏀«🏀ˇ»🏀«🏀ˇ» + "}); + cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); + cx.assert_editor_state(indoc! {" + a + «🏀ˇ»🏀«🏀ˇ»🏀«🏀ˇ» + "}); +} + +#[gpui::test] +async fn test_tab_in_leading_whitespace_auto_indents_lines(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + let language = Arc::new( + Language::new( + LanguageConfig::default(), + Some(tree_sitter_rust::LANGUAGE.into()), + ) + .with_indents_query(r#"(_ "(" ")" @end) @indent"#) + .unwrap(), + ); + cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); + + // test when all cursors are not at suggested indent + // then simply move to their suggested indent location + cx.set_state(indoc! {" + const a: B = ( + c( + ˇ + ˇ ) + ); + "}); + cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); + cx.assert_editor_state(indoc! {" + const a: B = ( + c( + ˇ + ˇ) + ); + "}); + + // test cursor already at suggested indent not moving when + // other cursors are yet to reach their suggested indents + cx.set_state(indoc! {" + ˇ + const a: B = ( + c( + d( + ˇ + ) + ˇ + ˇ ) + ); + "}); + cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); + cx.assert_editor_state(indoc! {" + ˇ + const a: B = ( + c( + d( + ˇ + ) + ˇ + ˇ) + ); + "}); + // test when all cursors are at suggested indent then tab is inserted + cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); + cx.assert_editor_state(indoc! {" + ˇ + const a: B = ( + c( + d( + ˇ + ) + ˇ + ˇ) + ); + "}); + + // test when current indent is less than suggested indent, + // we adjust line to match suggested indent and move cursor to it + // + // when no other cursor is at word boundary, all of them should move + cx.set_state(indoc! {" + const a: B = ( + c( + d( + ˇ + ˇ ) + ˇ ) + ); + "}); + cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); + cx.assert_editor_state(indoc! {" + const a: B = ( + c( + d( + ˇ + ˇ) + ˇ) + ); + "}); + + // test when current indent is less than suggested indent, + // we adjust line to match suggested indent and move cursor to it + // + // when some other cursor is at word boundary, it should not move + cx.set_state(indoc! {" + const a: B = ( + c( + d( + ˇ + ˇ ) + ˇ) + ); + "}); + cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); + cx.assert_editor_state(indoc! {" + const a: B = ( + c( + d( + ˇ + ˇ) + ˇ) + ); + "}); + + // test when current indent is more than suggested indent, + // we just move cursor to current indent instead of suggested indent + // + // when no other cursor is at word boundary, all of them should move + cx.set_state(indoc! {" + const a: B = ( + c( + d( + ˇ + ˇ ) + ˇ ) + ); + "}); + cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); + cx.assert_editor_state(indoc! {" + const a: B = ( + c( + d( + ˇ + ˇ) + ˇ) + ); + "}); + cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); + cx.assert_editor_state(indoc! {" + const a: B = ( + c( + d( + ˇ + ˇ) + ˇ) + ); + "}); + + // test when current indent is more than suggested indent, + // we just move cursor to current indent instead of suggested indent + // + // when some other cursor is at word boundary, it doesn't move + cx.set_state(indoc! {" + const a: B = ( + c( + d( + ˇ + ˇ ) + ˇ) + ); + "}); + cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); + cx.assert_editor_state(indoc! {" + const a: B = ( + c( + d( + ˇ + ˇ) + ˇ) + ); + "}); + + // handle auto-indent when there are multiple cursors on the same line + cx.set_state(indoc! {" + const a: B = ( + c( + ˇ ˇ + ˇ ) + ); + "}); + cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); + cx.assert_editor_state(indoc! {" + const a: B = ( + c( + ˇ + ˇ) + ); + "}); +} + +#[gpui::test] +async fn test_tab_after_closing_comment_delimiter(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + cx.update_buffer(|buffer, cx| { + buffer.set_language( + Some(languages::language("go", tree_sitter_go::LANGUAGE.into())), + cx, + ) + }); + + // The suggested indent is taken from the line the comment opened on, so that + // it agrees with what pressing enter after the closing delimiter produces. + cx.set_state("func test() {\n\t/**\n\t * doc\n\t */\nˇ\n}"); + cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); + cx.assert_editor_state("func test() {\n\t/**\n\t * doc\n\t */\n\tˇ\n}"); +} + +#[gpui::test] +async fn test_tab_with_mixed_whitespace_txt(cx: &mut TestAppContext) { + init_test(cx, |settings| { + settings.defaults.tab_size = NonZeroU32::new(3) + }); + + let mut cx = EditorTestContext::new(cx).await; + cx.set_state(indoc! {" + ˇ + \t ˇ + \t ˇ + \t ˇ + \t \t\t \t \t\t \t\t \t \t ˇ + "}); + + cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); + cx.assert_editor_state(indoc! {" + ˇ + \t ˇ + \t ˇ + \t ˇ + \t \t\t \t \t\t \t\t \t \t ˇ + "}); +} + +#[gpui::test] +async fn test_tab_with_mixed_whitespace_rust(cx: &mut TestAppContext) { + init_test(cx, |settings| { + settings.defaults.tab_size = NonZeroU32::new(4) + }); + + let language = Arc::new( + Language::new( + LanguageConfig::default(), + Some(tree_sitter_rust::LANGUAGE.into()), + ) + .with_indents_query(r#"(_ "{" "}" @end) @indent"#) + .unwrap(), + ); + + let mut cx = EditorTestContext::new(cx).await; + cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); + cx.set_state(indoc! {" + fn a() { + if b { + \t ˇc + } + } + "}); + + cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); + cx.assert_editor_state(indoc! {" + fn a() { + if b { + ˇc + } + } + "}); +} + +#[gpui::test] +async fn test_indent_outdent(cx: &mut TestAppContext) { + init_test(cx, |settings| { + settings.defaults.tab_size = NonZeroU32::new(4); + }); + + let mut cx = EditorTestContext::new(cx).await; + + cx.set_state(indoc! {" + «oneˇ» «twoˇ» + three + four + "}); + cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); + cx.assert_editor_state(indoc! {" + «oneˇ» «twoˇ» + three + four + "}); + + cx.update_editor(|e, window, cx| e.backtab(&Backtab, window, cx)); + cx.assert_editor_state(indoc! {" + «oneˇ» «twoˇ» + three + four + "}); + + // select across line ending + cx.set_state(indoc! {" + one two + t«hree + ˇ» four + "}); + cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); + cx.assert_editor_state(indoc! {" + one two + t«hree + ˇ» four + "}); + + cx.update_editor(|e, window, cx| e.backtab(&Backtab, window, cx)); + cx.assert_editor_state(indoc! {" + one two + t«hree + ˇ» four + "}); + + // Ensure that indenting/outdenting works when the cursor is at column 0. + cx.set_state(indoc! {" + one two + ˇthree + four + "}); + cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); + cx.assert_editor_state(indoc! {" + one two + ˇthree + four + "}); + + cx.set_state(indoc! {" + one two + ˇ three + four + "}); + cx.update_editor(|e, window, cx| e.backtab(&Backtab, window, cx)); + cx.assert_editor_state(indoc! {" + one two + ˇthree + four + "}); +} + +#[gpui::test] +async fn test_indent_yaml_comments_with_multiple_cursors(cx: &mut TestAppContext) { + // This is a regression test for issue #33761 + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + let yaml_language = languages::language("yaml", tree_sitter_yaml::LANGUAGE.into()); + cx.update_buffer(|buffer, cx| buffer.set_language(Some(yaml_language), cx)); + + cx.set_state( + r#"ˇ# ingress: +ˇ# api: +ˇ# enabled: false +ˇ# pathType: Prefix +ˇ# console: +ˇ# enabled: false +ˇ# pathType: Prefix +"#, + ); + + // Press tab to indent all lines + cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); + + cx.assert_editor_state( + r#" ˇ# ingress: + ˇ# api: + ˇ# enabled: false + ˇ# pathType: Prefix + ˇ# console: + ˇ# enabled: false + ˇ# pathType: Prefix +"#, + ); +} + +#[gpui::test] +async fn test_indent_yaml_non_comments_with_multiple_cursors(cx: &mut TestAppContext) { + // This is a test to make sure our fix for issue #33761 didn't break anything + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + let yaml_language = languages::language("yaml", tree_sitter_yaml::LANGUAGE.into()); + cx.update_buffer(|buffer, cx| buffer.set_language(Some(yaml_language), cx)); + + cx.set_state( + r#"ˇingress: +ˇ api: +ˇ enabled: false +ˇ pathType: Prefix +"#, + ); + + // Press tab to indent all lines + cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); + + cx.assert_editor_state( + r#"ˇingress: + ˇapi: + ˇenabled: false + ˇpathType: Prefix +"#, + ); +} + +#[gpui::test] +async fn test_multicursor_input_preserves_yaml_indentation(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + let yaml_language = languages::language("yaml", tree_sitter_yaml::LANGUAGE.into()); + cx.update_buffer(|buffer, cx| buffer.set_language(Some(yaml_language), cx)); + + let initial_state = indoc! {r#" + ˇcoverage: + ˇrange: 40..60 + ˇstatus: + ˇpatch: off + ˇproject: + ˇdefault: + ˇinformational: true + + ˇ# Don't leave comments on PRs + ˇcomment: false + "#}; + + for input in ["2", "#"] { + cx.set_state(initial_state); + cx.update_editor(|editor, window, cx| editor.handle_input(input, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(&initial_state.replace('ˇ', &format!("{input}ˇ"))); + + // Recreate the cursors and delete the inserted characters, as in #21334. + cx.update_editor(|editor, window, cx| editor.cancel(&Cancel, window, cx)); + cx.set_selections_state(&initial_state.replace('ˇ', &format!("ˇ{input}"))); + cx.update_editor(|editor, window, cx| editor.delete(&Delete, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(initial_state); + } + + // A multiline replacement must not reindent the other single-line edits. + cx.set_state(indoc! {" + ˇroot: + ˇchild: + ˇleaf: 1 + replacement: + « first: 1 + second: 2ˇ» + "}); + cx.update_editor(|editor, window, cx| editor.handle_input("2", window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + 2ˇroot: + 2ˇchild: + 2ˇleaf: 1 + replacement: + 2ˇ + "}); +} + +#[gpui::test] +async fn test_multicursor_input_autoindents_multiline_replacements(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + let python_language = languages::language("python", tree_sitter_python::LANGUAGE.into()); + cx.update_buffer(|buffer, cx| buffer.set_language(Some(python_language), cx)); + + cx.set_state(indoc! {" + def f(): + « a = 1 + b = 2ˇ» + def g(): + « a = 1 + b = 2ˇ» + "}); + + cx.update_editor(|editor, window, cx| editor.handle_input("pass", window, cx)); + cx.wait_for_autoindent_applied().await; + + assert_eq!( + cx.buffer_text(), + indoc! {" + def f(): + pass + def g(): + pass + "} + ); +} + +#[gpui::test] +async fn test_tab_indents_selected_yaml_block(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + let yaml_language = languages::language("yaml", tree_sitter_yaml::LANGUAGE.into()); + cx.update_buffer(|buffer, cx| buffer.set_language(Some(yaml_language), cx)); + + cx.set_state(indoc! {" + «foo: + - bar + - zop + x: + q + bar: + qˇ» + "}); + + cx.update_editor(|editor, window, cx| editor.tab(&Tab, window, cx)); + + assert_eq!( + cx.buffer_text(), + indoc! {" + \x20 foo: + - bar + - zop + x: + q + bar: + q + "} + ); +} + +#[gpui::test] +async fn test_tab_indents_overlapping_selections_consistently(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + cx.set_state(indoc! {" + \x20 «firstˇ»: «1 + \x20 second: 2 + \x20 third: 3ˇ» + "}); + + cx.update_editor(|editor, window, cx| editor.tab(&Tab, window, cx)); + + cx.assert_editor_state(indoc! {" + \x20 «firstˇ»: «1 + \x20 second: 2 + \x20 third: 3ˇ» + "}); +} + +#[gpui::test] +async fn test_indent_outdent_with_hard_tabs(cx: &mut TestAppContext) { + init_test(cx, |settings| { + settings.defaults.hard_tabs = Some(true); + }); + + let mut cx = EditorTestContext::new(cx).await; + + // select two ranges on one line + cx.set_state(indoc! {" + «oneˇ» «twoˇ» + three + four + "}); + cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); + cx.assert_editor_state(indoc! {" + \t«oneˇ» «twoˇ» + three + four + "}); + cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); + cx.assert_editor_state(indoc! {" + \t\t«oneˇ» «twoˇ» + three + four + "}); + cx.update_editor(|e, window, cx| e.backtab(&Backtab, window, cx)); + cx.assert_editor_state(indoc! {" + \t«oneˇ» «twoˇ» + three + four + "}); + cx.update_editor(|e, window, cx| e.backtab(&Backtab, window, cx)); + cx.assert_editor_state(indoc! {" + «oneˇ» «twoˇ» + three + four + "}); + + // select across a line ending + cx.set_state(indoc! {" + one two + t«hree + ˇ»four + "}); + cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); + cx.assert_editor_state(indoc! {" + one two + \tt«hree + ˇ»four + "}); + cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); + cx.assert_editor_state(indoc! {" + one two + \t\tt«hree + ˇ»four + "}); + cx.update_editor(|e, window, cx| e.backtab(&Backtab, window, cx)); + cx.assert_editor_state(indoc! {" + one two + \tt«hree + ˇ»four + "}); + cx.update_editor(|e, window, cx| e.backtab(&Backtab, window, cx)); + cx.assert_editor_state(indoc! {" + one two + t«hree + ˇ»four + "}); + + // Ensure that indenting/outdenting works when the cursor is at column 0. + cx.set_state(indoc! {" + one two + ˇthree + four + "}); + cx.update_editor(|e, window, cx| e.backtab(&Backtab, window, cx)); + cx.assert_editor_state(indoc! {" + one two + ˇthree + four + "}); + cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); + cx.assert_editor_state(indoc! {" + one two + \tˇthree + four + "}); + cx.update_editor(|e, window, cx| e.backtab(&Backtab, window, cx)); + cx.assert_editor_state(indoc! {" + one two + ˇthree + four + "}); +} + +#[gpui::test] +fn test_indent_outdent_with_excerpts(cx: &mut TestAppContext) { + init_test(cx, |settings| { + settings.languages.0.extend([ + ( + "TOML".into(), + LanguageSettingsContent { + tab_size: NonZeroU32::new(2), + ..Default::default() + }, + ), + ( + "Rust".into(), + LanguageSettingsContent { + tab_size: NonZeroU32::new(4), + ..Default::default() + }, + ), + ]); + }); + + let toml_language = Arc::new(Language::new( + LanguageConfig { + name: "TOML".into(), + ..Default::default() + }, + None, + )); + let rust_language = Arc::new(Language::new( + LanguageConfig { + name: "Rust".into(), + ..Default::default() + }, + None, + )); + + let toml_buffer = + cx.new(|cx| Buffer::local("a = 1\nb = 2\n", cx).with_language(toml_language, cx)); + let rust_buffer = + cx.new(|cx| Buffer::local("const c: usize = 3;\n", cx).with_language(rust_language, cx)); + let multibuffer = cx.new(|cx| { + let mut multibuffer = MultiBuffer::new(ReadWrite); + multibuffer.set_excerpts_for_path( + PathKey::sorted(0), + toml_buffer.clone(), + [Point::new(0, 0)..Point::new(2, 0)], + 0, + cx, + ); + multibuffer.set_excerpts_for_path( + PathKey::sorted(1), + rust_buffer.clone(), + [Point::new(0, 0)..Point::new(1, 0)], + 0, + cx, + ); + multibuffer + }); + + cx.add_window(|window, cx| { + let mut editor = build_editor(multibuffer, window, cx); + + assert_eq!( + editor.text(cx), + indoc! {" + a = 1 + b = 2 + + const c: usize = 3; + "} + ); + + select_ranges( + &mut editor, + indoc! {" + «aˇ» = 1 + b = 2 + + «const c:ˇ» usize = 3; + "}, + window, + cx, + ); + + editor.tab(&Tab, window, cx); + assert_text_with_selections( + &mut editor, + indoc! {" + «aˇ» = 1 + b = 2 + + «const c:ˇ» usize = 3; + "}, + cx, + ); + editor.backtab(&Backtab, window, cx); + assert_text_with_selections( + &mut editor, + indoc! {" + «aˇ» = 1 + b = 2 + + «const c:ˇ» usize = 3; + "}, + cx, + ); + + editor + }); +} + +#[gpui::test] +async fn test_backspace(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + // Basic backspace + cx.set_state(indoc! {" + onˇe two three + fou«rˇ» five six + seven «ˇeight nine + »ten + "}); + cx.update_editor(|e, window, cx| e.backspace(&Backspace, window, cx)); + cx.assert_editor_state(indoc! {" + oˇe two three + fouˇ five six + seven ˇten + "}); + + // Test backspace inside and around indents + cx.set_state(indoc! {" + zero + ˇone + ˇtwo + ˇ ˇ ˇ three + ˇ ˇ four + "}); + cx.update_editor(|e, window, cx| e.backspace(&Backspace, window, cx)); + cx.assert_editor_state(indoc! {" + zero + ˇone + ˇtwo + ˇ threeˇ four + "}); +} + +#[gpui::test] +async fn test_delete(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + cx.set_state(indoc! {" + onˇe two three + fou«rˇ» five six + seven «ˇeight nine + »ten + "}); + cx.update_editor(|e, window, cx| e.delete(&Delete, window, cx)); + cx.assert_editor_state(indoc! {" + onˇ two three + fouˇ five six + seven ˇten + "}); +} + +#[gpui::test] +fn test_delete_line(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let editor = cx.add_window(|window, cx| { + let buffer = MultiBuffer::build_simple("abc\ndef\nghi\n", cx); + build_editor(buffer, window, cx) + }); + _ = editor.update(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(0), 1)..DisplayPoint::new(DisplayRow(0), 1), + DisplayPoint::new(DisplayRow(1), 0)..DisplayPoint::new(DisplayRow(1), 1), + DisplayPoint::new(DisplayRow(3), 0)..DisplayPoint::new(DisplayRow(3), 0), + ]) + }); + editor.delete_line(&DeleteLine, window, cx); + assert_eq!(editor.display_text(cx), "ghi"); + assert_eq!( + display_ranges(editor, cx), + vec![ + DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 0), + DisplayPoint::new(DisplayRow(0), 1)..DisplayPoint::new(DisplayRow(0), 1), + ] + ); + }); + + let editor = cx.add_window(|window, cx| { + let buffer = MultiBuffer::build_simple("abc\ndef\nghi\n", cx); + build_editor(buffer, window, cx) + }); + _ = editor.update(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(2), 0)..DisplayPoint::new(DisplayRow(0), 1) + ]) + }); + editor.delete_line(&DeleteLine, window, cx); + assert_eq!(editor.display_text(cx), "ghi\n"); + assert_eq!( + display_ranges(editor, cx), + vec![DisplayPoint::new(DisplayRow(0), 1)..DisplayPoint::new(DisplayRow(0), 1)] + ); + }); + + let editor = cx.add_window(|window, cx| { + let buffer = MultiBuffer::build_simple("abc\ndef\nghi\n\njkl\nmno", cx); + build_editor(buffer, window, cx) + }); + _ = editor.update(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(0), 1)..DisplayPoint::new(DisplayRow(2), 1) + ]) + }); + editor.delete_line(&DeleteLine, window, cx); + assert_eq!(editor.display_text(cx), "\njkl\nmno"); + assert_eq!( + display_ranges(editor, cx), + vec![DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 0)] + ); + }); +} + +#[gpui::test] +fn test_join_lines_with_single_selection(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + cx.add_window(|window, cx| { + let buffer = MultiBuffer::build_simple("aaa\nbbb\nccc\nddd\n\n", cx); + let mut editor = build_editor(buffer.clone(), window, cx); + let buffer = buffer.read(cx).as_singleton().unwrap(); + + assert_eq!( + editor + .selections + .ranges::(&editor.display_snapshot(cx)), + &[Point::new(0, 0)..Point::new(0, 0)] + ); + + // When on single line, replace newline at end by space + editor.join_lines(&JoinLines, window, cx); + assert_eq!(buffer.read(cx).text(), "aaa bbb\nccc\nddd\n\n"); + assert_eq!( + editor + .selections + .ranges::(&editor.display_snapshot(cx)), + &[Point::new(0, 3)..Point::new(0, 3)] + ); + + editor.undo(&Undo, window, cx); + assert_eq!(buffer.read(cx).text(), "aaa\nbbb\nccc\nddd\n\n"); + + // Select a full line, i.e. start of the first line to the start of the second line + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_ranges([Point::new(0, 0)..Point::new(1, 0)]) + }); + editor.join_lines(&JoinLines, window, cx); + assert_eq!(buffer.read(cx).text(), "aaa bbb\nccc\nddd\n\n"); + + editor.undo(&Undo, window, cx); + assert_eq!(buffer.read(cx).text(), "aaa\nbbb\nccc\nddd\n\n"); + + // Select two full lines + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_ranges([Point::new(0, 0)..Point::new(2, 0)]) + }); + editor.join_lines(&JoinLines, window, cx); + + // Only the selected lines should be joined, not the third. + assert_eq!( + buffer.read(cx).text(), + "aaa bbb\nccc\nddd\n\n", + "only the two selected lines (a and b) should be joined" + ); + + // When multiple lines are selected, remove newlines that are spanned by the selection + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_ranges([Point::new(0, 5)..Point::new(2, 2)]) + }); + editor.join_lines(&JoinLines, window, cx); + assert_eq!(buffer.read(cx).text(), "aaa bbb ccc ddd\n\n"); + assert_eq!( + editor + .selections + .ranges::(&editor.display_snapshot(cx)), + &[Point::new(0, 11)..Point::new(0, 11)] + ); + + // Undo should be transactional + editor.undo(&Undo, window, cx); + assert_eq!(buffer.read(cx).text(), "aaa bbb\nccc\nddd\n\n"); + assert_eq!( + editor + .selections + .ranges::(&editor.display_snapshot(cx)), + &[Point::new(0, 5)..Point::new(2, 2)] + ); + + // When joining an empty line don't insert a space + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_ranges([Point::new(2, 1)..Point::new(2, 2)]) + }); + editor.join_lines(&JoinLines, window, cx); + assert_eq!(buffer.read(cx).text(), "aaa bbb\nccc\nddd\n"); + assert_eq!( + editor + .selections + .ranges::(&editor.display_snapshot(cx)), + [Point::new(2, 3)..Point::new(2, 3)] + ); + + // We can remove trailing newlines + editor.join_lines(&JoinLines, window, cx); + assert_eq!(buffer.read(cx).text(), "aaa bbb\nccc\nddd"); + assert_eq!( + editor + .selections + .ranges::(&editor.display_snapshot(cx)), + [Point::new(2, 3)..Point::new(2, 3)] + ); + + // We don't blow up on the last line + editor.join_lines(&JoinLines, window, cx); + assert_eq!(buffer.read(cx).text(), "aaa bbb\nccc\nddd"); + assert_eq!( + editor + .selections + .ranges::(&editor.display_snapshot(cx)), + [Point::new(2, 3)..Point::new(2, 3)] + ); + + // reset to test indentation + editor.buffer.update(cx, |buffer, cx| { + buffer.edit( + [ + (Point::new(1, 0)..Point::new(1, 2), " "), + (Point::new(2, 0)..Point::new(2, 3), " \n\td"), + ], + None, + cx, + ) + }); + + // We remove any leading spaces + assert_eq!(buffer.read(cx).text(), "aaa bbb\n c\n \n\td"); + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_ranges([Point::new(0, 1)..Point::new(0, 1)]) + }); + editor.join_lines(&JoinLines, window, cx); + assert_eq!(buffer.read(cx).text(), "aaa bbb c\n \n\td"); + + // We don't insert a space for a line containing only spaces + editor.join_lines(&JoinLines, window, cx); + assert_eq!(buffer.read(cx).text(), "aaa bbb c\n\td"); + + // We ignore any leading tabs + editor.join_lines(&JoinLines, window, cx); + assert_eq!(buffer.read(cx).text(), "aaa bbb c d"); + + editor + }); +} + +#[gpui::test] +fn test_join_lines_with_multi_selection(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + cx.add_window(|window, cx| { + let buffer = MultiBuffer::build_simple("aaa\nbbb\nccc\nddd\n\n", cx); + let mut editor = build_editor(buffer.clone(), window, cx); + let buffer = buffer.read(cx).as_singleton().unwrap(); + + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_ranges([ + Point::new(0, 2)..Point::new(1, 1), + Point::new(1, 2)..Point::new(1, 2), + Point::new(3, 1)..Point::new(3, 2), + ]) + }); + + editor.join_lines(&JoinLines, window, cx); + assert_eq!(buffer.read(cx).text(), "aaa bbb ccc\nddd\n"); + + assert_eq!( + editor + .selections + .ranges::(&editor.display_snapshot(cx)), + [ + Point::new(0, 7)..Point::new(0, 7), + Point::new(1, 3)..Point::new(1, 3) + ] + ); + editor + }); +} + +#[gpui::test] +async fn test_join_lines_with_git_diff_base(executor: BackgroundExecutor, cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + let diff_base = r#" + Line 0 + Line 1 + Line 2 + Line 3 + "# + .unindent(); + + cx.set_state( + &r#" + ˇLine 0 + Line 1 + Line 2 + Line 3 + "# + .unindent(), + ); + + cx.set_head_text(&diff_base); + executor.run_until_parked(); + + // Join lines + cx.update_editor(|editor, window, cx| { + editor.join_lines(&JoinLines, window, cx); + }); + executor.run_until_parked(); + + cx.assert_editor_state( + &r#" + Line 0ˇ Line 1 + Line 2 + Line 3 + "# + .unindent(), + ); + // Join again + cx.update_editor(|editor, window, cx| { + editor.join_lines(&JoinLines, window, cx); + }); + executor.run_until_parked(); + + cx.assert_editor_state( + &r#" + Line 0 Line 1ˇ Line 2 + Line 3 + "# + .unindent(), + ); +} + +#[gpui::test] +async fn test_join_lines_strips_comment_prefix(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + { + let mut cx = EditorTestContext::new(cx).await; + cx.update_buffer(|buffer, cx| buffer.set_language(Some(rust_lang()), cx)); + + // Strips the comment prefix (with trailing space) from the joined-in line. + cx.set_state(indoc! {" + // ˇfoo + // bar + "}); + cx.update_editor(|e, window, cx| e.join_lines(&JoinLines, window, cx)); + cx.assert_editor_state(indoc! {" + // fooˇ bar + "}); + + // Strips the longer doc-comment prefix when both `//` and `///` match. + cx.set_state(indoc! {" + /// ˇfoo + /// bar + "}); + cx.update_editor(|e, window, cx| e.join_lines(&JoinLines, window, cx)); + cx.assert_editor_state(indoc! {" + /// fooˇ bar + "}); + + // Does not strip when the second line is a regular line (no comment prefix). + cx.set_state(indoc! {" + // ˇfoo + bar + "}); + cx.update_editor(|e, window, cx| e.join_lines(&JoinLines, window, cx)); + cx.assert_editor_state(indoc! {" + // fooˇ bar + "}); + + // No-whitespace join also strips the comment prefix. + cx.set_state(indoc! {" + // ˇfoo + // bar + "}); + cx.update_editor(|e, window, cx| e.join_lines_impl(false, window, cx)); + cx.assert_editor_state(indoc! {" + // fooˇbar + "}); + + // Strips even when the joined-in line is just the bare prefix (no trailing space). + cx.set_state(indoc! {" + // ˇfoo + // + "}); + cx.update_editor(|e, window, cx| e.join_lines(&JoinLines, window, cx)); + cx.assert_editor_state(indoc! {" + // fooˇ + "}); + + // Mixed line comment prefix types: the longer matching prefix is stripped. + cx.set_state(indoc! {" + // ˇfoo + /// bar + "}); + cx.update_editor(|e, window, cx| e.join_lines(&JoinLines, window, cx)); + cx.assert_editor_state(indoc! {" + // fooˇ bar + "}); + + // Strips block comment body prefix (`* `) from the joined-in line. + cx.set_state(indoc! {" + /* + * ˇfoo + * bar + */ + "}); + cx.update_editor(|e, window, cx| e.join_lines(&JoinLines, window, cx)); + cx.assert_editor_state(indoc! {" + /* + * fooˇ bar + */ + "}); + + // Strips bare block comment body prefix (`*` without trailing space). + cx.set_state(indoc! {" + /* + * ˇfoo + * + */ + "}); + cx.update_editor(|e, window, cx| e.join_lines(&JoinLines, window, cx)); + cx.assert_editor_state(indoc! {" + /* + * fooˇ + */ + "}); + } + + { + let markdown_language = Arc::new(Language::new( + LanguageConfig { + unordered_list: vec!["- ".into(), "* ".into(), "+ ".into()], + ..LanguageConfig::default() + }, + None, + )); + + let mut cx = EditorTestContext::new(cx).await; + cx.update_buffer(|buffer, cx| buffer.set_language(Some(markdown_language), cx)); + + // Strips the `- ` list marker from the joined-in line. + cx.set_state(indoc! {" + - ˇfoo + - bar + "}); + cx.update_editor(|e, window, cx| e.join_lines(&JoinLines, window, cx)); + cx.assert_editor_state(indoc! {" + - fooˇ bar + "}); + + // Strips the `* ` list marker from the joined-in line. + cx.set_state(indoc! {" + * ˇfoo + * bar + "}); + cx.update_editor(|e, window, cx| e.join_lines(&JoinLines, window, cx)); + cx.assert_editor_state(indoc! {" + * fooˇ bar + "}); + + // Strips the `+ ` list marker from the joined-in line. + cx.set_state(indoc! {" + + ˇfoo + + bar + "}); + cx.update_editor(|e, window, cx| e.join_lines(&JoinLines, window, cx)); + cx.assert_editor_state(indoc! {" + + fooˇ bar + "}); + + cx.set_state(indoc! {" + fooˇ + *bar* + "}); + cx.update_editor(|e, window, cx| e.join_lines(&JoinLines, window, cx)); + cx.assert_editor_state(indoc! {" + fooˇ *bar* + "}); + + cx.set_state(indoc! {" + * ˇfoo + * + "}); + cx.update_editor(|e, window, cx| e.join_lines(&JoinLines, window, cx)); + cx.assert_editor_state(indoc! {" + * fooˇ + "}); + + // No-whitespace join also strips the list marker. + cx.set_state(indoc! {" + - ˇfoo + - bar + "}); + cx.update_editor(|e, window, cx| e.join_lines_impl(false, window, cx)); + cx.assert_editor_state(indoc! {" + - fooˇbar + "}); + } +} + +#[gpui::test] +async fn test_join_lines_preserves_rust_operators(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + cx.update_buffer(|buffer, cx| buffer.set_language(Some(rust_lang()), cx)); + + for insert_whitespace in [true, false] { + let separator = if insert_whitespace { " " } else { "" }; + for indent in ["", " ", "\t"] { + for (first_line, next_line) in [ + ("let value =", "*pointer;"), + ("let value =", "* pointer;"), + ("let value =", "**pointer;"), + ("let value =", "*指针;"), + ("let value = 2", "* 3;"), + ("let value = \"text", "*text\";"), + ("let value = r#\"text", "*text\"#;"), + ] { + cx.set_state(&formatdoc! {" + fn main() {{ + {first_line}ˇ + {indent}{next_line} + }}"}); + cx.update_editor(|editor, window, cx| { + editor.join_lines_impl(insert_whitespace, window, cx) + }); + cx.assert_editor_state(&formatdoc! {" + fn main() {{ + {first_line}ˇ{separator}{next_line} + }}"}); + } + } + } +} + +#[gpui::test] +async fn test_join_lines_rust_block_comments(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + cx.update_buffer(|buffer, cx| buffer.set_language(Some(rust_lang()), cx)); + + for insert_whitespace in [true, false] { + let separator = if insert_whitespace { " " } else { "" }; + for start in ["/*", "/**", "/*!"] { + for next_line in ["* bar", "*bar"] { + cx.set_state(&formatdoc! {" + {start} + * fooˇ + {next_line} + */"}); + cx.update_editor(|editor, window, cx| { + editor.join_lines_impl(insert_whitespace, window, cx) + }); + cx.assert_editor_state(&formatdoc! {" + {start} + * fooˇ{separator}bar + */"}); + } + + cx.set_state(&formatdoc! {" + {start} + * fooˇ + * + */"}); + cx.update_editor(|editor, window, cx| { + editor.join_lines_impl(insert_whitespace, window, cx) + }); + cx.assert_editor_state(&formatdoc! {" + {start} + * fooˇ + */"}); + + cx.set_state(&formatdoc! {" + {start} + * fooˇ + */"}); + cx.update_editor(|editor, window, cx| { + editor.join_lines_impl(insert_whitespace, window, cx) + }); + cx.assert_editor_state(&formatdoc! {" + {start} + * fooˇ{separator}*/"}); + } + } +} + +#[gpui::test] +async fn test_custom_newlines_cause_no_false_positive_diffs( + executor: BackgroundExecutor, + cx: &mut TestAppContext, +) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + cx.set_state("Line 0\r\nLine 1\rˇ\nLine 2\r\nLine 3"); + cx.set_head_text("Line 0\r\nLine 1\r\nLine 2\r\nLine 3"); + executor.run_until_parked(); + + cx.update_editor(|editor, window, cx| { + let snapshot = editor.snapshot(window, cx); + assert_eq!( + snapshot + .buffer_snapshot() + .diff_hunks_in_range(MultiBufferOffset(0)..snapshot.buffer_snapshot().len()) + .collect::>(), + Vec::new(), + "Should not have any diffs for files with custom newlines" + ); + }); +} + +#[gpui::test] +async fn test_manipulate_immutable_lines_with_single_selection(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + // Test sort_lines_case_insensitive() + cx.set_state(indoc! {" + «z + y + x + Z + Y + Xˇ» + "}); + cx.update_editor(|e, window, cx| { + e.sort_lines_case_insensitive(&SortLinesCaseInsensitive, window, cx) + }); + cx.assert_editor_state(indoc! {" + «x + X + y + Y + z + Zˇ» + "}); + + // Test sort_lines_by_length() + // + // Demonstrates: + // - ∞ is 3 bytes UTF-8, but sorted by its char count (1) + // - sort is stable + cx.set_state(indoc! {" + «123 + æ + 12 + ∞ + 1 + æˇ» + "}); + cx.update_editor(|e, window, cx| e.sort_lines_by_length(&SortLinesByLength, window, cx)); + cx.assert_editor_state(indoc! {" + «æ + ∞ + 1 + æ + 12 + 123ˇ» + "}); + + // Test reverse_lines() + cx.set_state(indoc! {" + «5 + 4 + 3 + 2 + 1ˇ» + "}); + cx.update_editor(|e, window, cx| e.reverse_lines(&ReverseLines, window, cx)); + cx.assert_editor_state(indoc! {" + «1 + 2 + 3 + 4 + 5ˇ» + "}); + + // Skip testing shuffle_line() + + // From here on out, test more complex cases of manipulate_immutable_lines() with a single driver method: sort_lines_case_sensitive() + // Since all methods calling manipulate_immutable_lines() are doing the exact same general thing (reordering lines) + + // Don't manipulate when cursor is on single line, but expand the selection + cx.set_state(indoc! {" + ddˇdd + ccc + bb + a + "}); + cx.update_editor(|e, window, cx| { + e.sort_lines_case_sensitive(&SortLinesCaseSensitive, window, cx) + }); + cx.assert_editor_state(indoc! {" + «ddddˇ» + ccc + bb + a + "}); + + // Basic manipulate case + // Start selection moves to column 0 + // End of selection shrinks to fit shorter line + cx.set_state(indoc! {" + dd«d + ccc + bb + aaaaaˇ» + "}); + cx.update_editor(|e, window, cx| { + e.sort_lines_case_sensitive(&SortLinesCaseSensitive, window, cx) + }); + cx.assert_editor_state(indoc! {" + «aaaaa + bb + ccc + dddˇ» + "}); + + // Manipulate case with newlines + cx.set_state(indoc! {" + dd«d + ccc + + bb + aaaaa + + ˇ» + "}); + cx.update_editor(|e, window, cx| { + e.sort_lines_case_sensitive(&SortLinesCaseSensitive, window, cx) + }); + cx.assert_editor_state(indoc! {" + « + + aaaaa + bb + ccc + dddˇ» + + "}); + + // Adding new line + cx.set_state(indoc! {" + aa«a + bbˇ»b + "}); + cx.update_editor(|e, window, cx| { + e.manipulate_immutable_lines(window, cx, |lines| lines.push("added_line")) + }); + cx.assert_editor_state(indoc! {" + «aaa + bbb + added_lineˇ» + "}); + + // Removing line + cx.set_state(indoc! {" + aa«a + bbbˇ» + "}); + cx.update_editor(|e, window, cx| { + e.manipulate_immutable_lines(window, cx, |lines| { + lines.pop(); + }) + }); + cx.assert_editor_state(indoc! {" + «aaaˇ» + "}); + + // Removing all lines + cx.set_state(indoc! {" + aa«a + bbbˇ» + "}); + cx.update_editor(|e, window, cx| { + e.manipulate_immutable_lines(window, cx, |lines| { + lines.drain(..); + }) + }); + cx.assert_editor_state(indoc! {" + ˇ + "}); +} + +#[gpui::test] +async fn test_unique_lines_multi_selection(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + // Consider continuous selection as single selection + cx.set_state(indoc! {" + Aaa«aa + cˇ»c«c + bb + aaaˇ»aa + "}); + cx.update_editor(|e, window, cx| { + e.unique_lines_case_sensitive(&UniqueLinesCaseSensitive, window, cx) + }); + cx.assert_editor_state(indoc! {" + «Aaaaa + ccc + bb + aaaaaˇ» + "}); + + cx.set_state(indoc! {" + Aaa«aa + cˇ»c«c + bb + aaaˇ»aa + "}); + cx.update_editor(|e, window, cx| { + e.unique_lines_case_insensitive(&UniqueLinesCaseInsensitive, window, cx) + }); + cx.assert_editor_state(indoc! {" + «Aaaaa + ccc + bbˇ» + "}); + + // Consider non continuous selection as distinct dedup operations + cx.set_state(indoc! {" + «aaaaa + bb + aaaaa + aaaaaˇ» + + aaa«aaˇ» + "}); + cx.update_editor(|e, window, cx| { + e.unique_lines_case_sensitive(&UniqueLinesCaseSensitive, window, cx) + }); + cx.assert_editor_state(indoc! {" + «aaaaa + bbˇ» + + «aaaaaˇ» + "}); +} + +#[gpui::test] +async fn test_unique_lines_single_selection(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + cx.set_state(indoc! {" + «Aaa + aAa + Aaaˇ» + "}); + cx.update_editor(|e, window, cx| { + e.unique_lines_case_sensitive(&UniqueLinesCaseSensitive, window, cx) + }); + cx.assert_editor_state(indoc! {" + «Aaa + aAaˇ» + "}); + + cx.set_state(indoc! {" + «Aaa + aAa + aaAˇ» + "}); + cx.update_editor(|e, window, cx| { + e.unique_lines_case_insensitive(&UniqueLinesCaseInsensitive, window, cx) + }); + cx.assert_editor_state(indoc! {" + «Aaaˇ» + "}); +} + +#[gpui::test] +async fn test_wrap_in_tag_single_selection(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + let js_language = Arc::new(Language::new( + LanguageConfig { + name: "JavaScript".into(), + wrap_characters: Some(language::WrapCharactersConfig { + start_prefix: "<".into(), + start_suffix: ">".into(), + end_prefix: "".into(), + }), + ..LanguageConfig::default() + }, + None, + )); + + cx.update_buffer(|buffer, cx| buffer.set_language(Some(js_language), cx)); + + cx.set_state(indoc! {" + «testˇ» + "}); + cx.update_editor(|e, window, cx| e.wrap_selections_in_tag(&WrapSelectionsInTag, window, cx)); + cx.assert_editor_state(indoc! {" + <«ˇ»>test + "}); + + cx.set_state(indoc! {" + «test + testˇ» + "}); + cx.update_editor(|e, window, cx| e.wrap_selections_in_tag(&WrapSelectionsInTag, window, cx)); + cx.assert_editor_state(indoc! {" + <«ˇ»>test + test + "}); + + cx.set_state(indoc! {" + teˇst + "}); + cx.update_editor(|e, window, cx| e.wrap_selections_in_tag(&WrapSelectionsInTag, window, cx)); + cx.assert_editor_state(indoc! {" + te<«ˇ»>st + "}); +} + +#[gpui::test] +async fn test_wrap_in_tag_multi_selection(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + let js_language = Arc::new(Language::new( + LanguageConfig { + name: "JavaScript".into(), + wrap_characters: Some(language::WrapCharactersConfig { + start_prefix: "<".into(), + start_suffix: ">".into(), + end_prefix: "".into(), + }), + ..LanguageConfig::default() + }, + None, + )); + + cx.update_buffer(|buffer, cx| buffer.set_language(Some(js_language), cx)); + + cx.set_state(indoc! {" + «testˇ» + «testˇ» «testˇ» + «testˇ» + "}); + cx.update_editor(|e, window, cx| e.wrap_selections_in_tag(&WrapSelectionsInTag, window, cx)); + cx.assert_editor_state(indoc! {" + <«ˇ»>test + <«ˇ»>test <«ˇ»>test + <«ˇ»>test + "}); + + cx.set_state(indoc! {" + «test + testˇ» + «test + testˇ» + "}); + cx.update_editor(|e, window, cx| e.wrap_selections_in_tag(&WrapSelectionsInTag, window, cx)); + cx.assert_editor_state(indoc! {" + <«ˇ»>test + test + <«ˇ»>test + test + "}); +} + +#[gpui::test] +async fn test_wrap_in_tag_does_nothing_in_unsupported_languages(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + let plaintext_language = Arc::new(Language::new( + LanguageConfig { + name: "Plain Text".into(), + ..LanguageConfig::default() + }, + None, + )); + + cx.update_buffer(|buffer, cx| buffer.set_language(Some(plaintext_language), cx)); + + cx.set_state(indoc! {" + «testˇ» + "}); + cx.update_editor(|e, window, cx| e.wrap_selections_in_tag(&WrapSelectionsInTag, window, cx)); + cx.assert_editor_state(indoc! {" + «testˇ» + "}); +} + +#[gpui::test] +async fn test_manipulate_immutable_lines_with_multi_selection(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + // Manipulate with multiple selections on a single line + cx.set_state(indoc! {" + dd«dd + cˇ»c«c + bb + aaaˇ»aa + "}); + cx.update_editor(|e, window, cx| { + e.sort_lines_case_sensitive(&SortLinesCaseSensitive, window, cx) + }); + cx.assert_editor_state(indoc! {" + «aaaaa + bb + ccc + ddddˇ» + "}); + + // Manipulate with multiple disjoin selections + cx.set_state(indoc! {" + 5« + 4 + 3 + 2 + 1ˇ» + + dd«dd + ccc + bb + aaaˇ»aa + "}); + cx.update_editor(|e, window, cx| { + e.sort_lines_case_sensitive(&SortLinesCaseSensitive, window, cx) + }); + cx.assert_editor_state(indoc! {" + «1 + 2 + 3 + 4 + 5ˇ» + + «aaaaa + bb + ccc + ddddˇ» + "}); + + // Adding lines on each selection + cx.set_state(indoc! {" + 2« + 1ˇ» + + bb«bb + aaaˇ»aa + "}); + cx.update_editor(|e, window, cx| { + e.manipulate_immutable_lines(window, cx, |lines| lines.push("added line")) + }); + cx.assert_editor_state(indoc! {" + «2 + 1 + added lineˇ» + + «bbbb + aaaaa + added lineˇ» + "}); + + // Removing lines on each selection + cx.set_state(indoc! {" + 2« + 1ˇ» + + bb«bb + aaaˇ»aa + "}); + cx.update_editor(|e, window, cx| { + e.manipulate_immutable_lines(window, cx, |lines| { + lines.pop(); + }) + }); + cx.assert_editor_state(indoc! {" + «2ˇ» + + «bbbbˇ» + "}); +} + +#[gpui::test] +async fn test_convert_indentation_to_spaces(cx: &mut TestAppContext) { + init_test(cx, |settings| { + settings.defaults.tab_size = NonZeroU32::new(3) + }); + + let mut cx = EditorTestContext::new(cx).await; + + // MULTI SELECTION + // Ln.1 "«" tests empty lines + // Ln.9 tests just leading whitespace + cx.set_state(indoc! {" + « + abc // No indentationˇ» + «\tabc // 1 tabˇ» + \t\tabc « ˇ» // 2 tabs + \t ab«c // Tab followed by space + \tabc // Space followed by tab (3 spaces should be the result) + \t \t \t \tabc // Mixed indentation (tab conversion depends on the column) + abˇ»ˇc ˇ ˇ // Already space indented« + \t + \tabc\tdef // Only the leading tab is manipulatedˇ» + "}); + cx.update_editor(|e, window, cx| { + e.convert_indentation_to_spaces(&ConvertIndentationToSpaces, window, cx); + }); + cx.assert_editor_state( + indoc! {" + « + abc // No indentation + abc // 1 tab + abc // 2 tabs + abc // Tab followed by space + abc // Space followed by tab (3 spaces should be the result) + abc // Mixed indentation (tab conversion depends on the column) + abc // Already space indented + · + abc\tdef // Only the leading tab is manipulatedˇ» + "} + .replace("·", "") + .as_str(), // · used as placeholder to prevent format-on-save from removing whitespace + ); + + // Test on just a few lines, the others should remain unchanged + // Only lines (3, 5, 10, 11) should change + cx.set_state( + indoc! {" + · + abc // No indentation + \tabcˇ // 1 tab + \t\tabc // 2 tabs + \t abcˇ // Tab followed by space + \tabc // Space followed by tab (3 spaces should be the result) + \t \t \t \tabc // Mixed indentation (tab conversion depends on the column) + abc // Already space indented + «\t + \tabc\tdef // Only the leading tab is manipulatedˇ» + "} + .replace("·", "") + .as_str(), // · used as placeholder to prevent format-on-save from removing whitespace + ); + cx.update_editor(|e, window, cx| { + e.convert_indentation_to_spaces(&ConvertIndentationToSpaces, window, cx); + }); + cx.assert_editor_state( + indoc! {" + · + abc // No indentation + « abc // 1 tabˇ» + \t\tabc // 2 tabs + « abc // Tab followed by spaceˇ» + \tabc // Space followed by tab (3 spaces should be the result) + \t \t \t \tabc // Mixed indentation (tab conversion depends on the column) + abc // Already space indented + « · + abc\tdef // Only the leading tab is manipulatedˇ» + "} + .replace("·", "") + .as_str(), // · used as placeholder to prevent format-on-save from removing whitespace + ); + + // SINGLE SELECTION + // Ln.1 "«" tests empty lines + // Ln.9 tests just leading whitespace + cx.set_state(indoc! {" + « + abc // No indentation + \tabc // 1 tab + \t\tabc // 2 tabs + \t abc // Tab followed by space + \tabc // Space followed by tab (3 spaces should be the result) + \t \t \t \tabc // Mixed indentation (tab conversion depends on the column) + abc // Already space indented + \t + \tabc\tdef // Only the leading tab is manipulatedˇ» + "}); + cx.update_editor(|e, window, cx| { + e.convert_indentation_to_spaces(&ConvertIndentationToSpaces, window, cx); + }); + cx.assert_editor_state( + indoc! {" + « + abc // No indentation + abc // 1 tab + abc // 2 tabs + abc // Tab followed by space + abc // Space followed by tab (3 spaces should be the result) + abc // Mixed indentation (tab conversion depends on the column) + abc // Already space indented + · + abc\tdef // Only the leading tab is manipulatedˇ» + "} + .replace("·", "") + .as_str(), // · used as placeholder to prevent format-on-save from removing whitespace + ); +} + +#[gpui::test] +async fn test_convert_indentation_to_tabs(cx: &mut TestAppContext) { + init_test(cx, |settings| { + settings.defaults.tab_size = NonZeroU32::new(3) + }); + + let mut cx = EditorTestContext::new(cx).await; + + // MULTI SELECTION + // Ln.1 "«" tests empty lines + // Ln.11 tests just leading whitespace + cx.set_state(indoc! {" + « + abˇ»ˇc // No indentation + abc ˇ ˇ // 1 space (< 3 so dont convert) + abc « // 2 spaces (< 3 so dont convert) + abc // 3 spaces (convert) + abc ˇ» // 5 spaces (1 tab + 2 spaces) + «\tˇ»\t«\tˇ»abc // Already tab indented + «\t abc // Tab followed by space + \tabc // Space followed by tab (should be consumed due to tab) + \t \t \t \tabc // Mixed indentation (first 3 spaces are consumed, the others are converted) + \tˇ» «\t + abcˇ» \t ˇˇˇ // Only the leading spaces should be converted + "}); + cx.update_editor(|e, window, cx| { + e.convert_indentation_to_tabs(&ConvertIndentationToTabs, window, cx); + }); + cx.assert_editor_state(indoc! {" + « + abc // No indentation + abc // 1 space (< 3 so dont convert) + abc // 2 spaces (< 3 so dont convert) + \tabc // 3 spaces (convert) + \t abc // 5 spaces (1 tab + 2 spaces) + \t\t\tabc // Already tab indented + \t abc // Tab followed by space + \tabc // Space followed by tab (should be consumed due to tab) + \t\t\t\t\tabc // Mixed indentation (first 3 spaces are consumed, the others are converted) + \t\t\t + \tabc \t // Only the leading spaces should be convertedˇ» + "}); + + // Test on just a few lines, the other should remain unchanged + // Only lines (4, 8, 11, 12) should change + cx.set_state( + indoc! {" + · + abc // No indentation + abc // 1 space (< 3 so dont convert) + abc // 2 spaces (< 3 so dont convert) + « abc // 3 spaces (convert)ˇ» + abc // 5 spaces (1 tab + 2 spaces) + \t\t\tabc // Already tab indented + \t abc // Tab followed by space + \tabc ˇ // Space followed by tab (should be consumed due to tab) + \t\t \tabc // Mixed indentation + \t \t \t \tabc // Mixed indentation + \t \tˇ + « abc \t // Only the leading spaces should be convertedˇ» + "} + .replace("·", "") + .as_str(), // · used as placeholder to prevent format-on-save from removing whitespace + ); + cx.update_editor(|e, window, cx| { + e.convert_indentation_to_tabs(&ConvertIndentationToTabs, window, cx); + }); + cx.assert_editor_state( + indoc! {" + · + abc // No indentation + abc // 1 space (< 3 so dont convert) + abc // 2 spaces (< 3 so dont convert) + «\tabc // 3 spaces (convert)ˇ» + abc // 5 spaces (1 tab + 2 spaces) + \t\t\tabc // Already tab indented + \t abc // Tab followed by space + «\tabc // Space followed by tab (should be consumed due to tab)ˇ» + \t\t \tabc // Mixed indentation + \t \t \t \tabc // Mixed indentation + «\t\t\t + \tabc \t // Only the leading spaces should be convertedˇ» + "} + .replace("·", "") + .as_str(), // · used as placeholder to prevent format-on-save from removing whitespace + ); + + // SINGLE SELECTION + // Ln.1 "«" tests empty lines + // Ln.11 tests just leading whitespace + cx.set_state(indoc! {" + « + abc // No indentation + abc // 1 space (< 3 so dont convert) + abc // 2 spaces (< 3 so dont convert) + abc // 3 spaces (convert) + abc // 5 spaces (1 tab + 2 spaces) + \t\t\tabc // Already tab indented + \t abc // Tab followed by space + \tabc // Space followed by tab (should be consumed due to tab) + \t \t \t \tabc // Mixed indentation (first 3 spaces are consumed, the others are converted) + \t \t + abc \t // Only the leading spaces should be convertedˇ» + "}); + cx.update_editor(|e, window, cx| { + e.convert_indentation_to_tabs(&ConvertIndentationToTabs, window, cx); + }); + cx.assert_editor_state(indoc! {" + « + abc // No indentation + abc // 1 space (< 3 so dont convert) + abc // 2 spaces (< 3 so dont convert) + \tabc // 3 spaces (convert) + \t abc // 5 spaces (1 tab + 2 spaces) + \t\t\tabc // Already tab indented + \t abc // Tab followed by space + \tabc // Space followed by tab (should be consumed due to tab) + \t\t\t\t\tabc // Mixed indentation (first 3 spaces are consumed, the others are converted) + \t\t\t + \tabc \t // Only the leading spaces should be convertedˇ» + "}); +} + +#[gpui::test] +async fn test_toggle_case(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + // If all lower case -> upper case + cx.set_state(indoc! {" + «hello worldˇ» + "}); + cx.update_editor(|e, window, cx| e.toggle_case(&ToggleCase, window, cx)); + cx.assert_editor_state(indoc! {" + «HELLO WORLDˇ» + "}); + + // If all upper case -> lower case + cx.set_state(indoc! {" + «HELLO WORLDˇ» + "}); + cx.update_editor(|e, window, cx| e.toggle_case(&ToggleCase, window, cx)); + cx.assert_editor_state(indoc! {" + «hello worldˇ» + "}); + + // If any upper case characters are identified -> lower case + // This matches JetBrains IDEs + cx.set_state(indoc! {" + «hEllo worldˇ» + "}); + cx.update_editor(|e, window, cx| e.toggle_case(&ToggleCase, window, cx)); + cx.assert_editor_state(indoc! {" + «hello worldˇ» + "}); +} + +#[gpui::test] +async fn test_convert_to_sentence_case(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + cx.set_state(indoc! {" + «implement-windows-supportˇ» + "}); + cx.update_editor(|e, window, cx| { + e.convert_to_sentence_case(&ConvertToSentenceCase, window, cx) + }); + cx.assert_editor_state(indoc! {" + «Implement windows supportˇ» + "}); +} + +#[gpui::test] +async fn test_convert_to_base64(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + // Encode a plain text selection + cx.set_state(indoc! {" + «helloˇ» + "}); + cx.update_editor(|e, window, cx| e.convert_to_base64(&ConvertToBase64, window, cx)); + cx.assert_editor_state(indoc! {" + «aGVsbG8=ˇ» + "}); + + // Decode a valid base64 selection + cx.set_state(indoc! {" + «aGVsbG8=ˇ» + "}); + cx.update_editor(|e, window, cx| e.convert_from_base64(&ConvertFromBase64, window, cx)); + cx.assert_editor_state(indoc! {" + «helloˇ» + "}); + + // Decode invalid base64 — should leave text unchanged + cx.set_state(indoc! {" + «not!!!ˇ» + "}); + cx.update_editor(|e, window, cx| e.convert_from_base64(&ConvertFromBase64, window, cx)); + cx.assert_editor_state(indoc! {" + «not!!!ˇ» + "}); +} + +#[gpui::test] +fn test_manipulate_text_handles_cross_excerpt_edit_that_applies_differently( + cx: &mut TestAppContext, +) { + init_test(cx, |_| {}); + + let buffer_1 = cx.new(|cx| { + let mut buffer = Buffer::local("ab", cx); + // The selected multibuffer range starts in this excerpt, but edits to + // it are skipped because the underlying buffer is read-only. + buffer.set_capability(language::Capability::ReadOnly, cx); + buffer + }); + let buffer_2 = cx.new(|cx| Buffer::local("cd", cx)); + let multibuffer = cx.new(|cx| { + let mut multibuffer = MultiBuffer::new(ReadWrite); + multibuffer.set_excerpts_for_path( + PathKey::sorted(0), + buffer_1.clone(), + [Point::new(0, 0)..Point::new(0, 2)], + 0, + cx, + ); + multibuffer.set_excerpts_for_path( + PathKey::sorted(1), + buffer_2.clone(), + [Point::new(0, 0)..Point::new(0, 2)], + 0, + cx, + ); + multibuffer + }); + + cx.add_window(|window, cx| { + let mut editor = build_editor(multibuffer, window, cx); + let len = editor.buffer().read(cx).len(cx); + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| { + selections.select_ranges([MultiBufferOffset(0)..len]) + }); + + // No-op transformations should not be sent through `MultiBuffer::edit`. + editor.manipulate_text(window, cx, |text| text.to_string()); + assert_eq!(buffer_1.read(cx).text(), "ab"); + assert_eq!(buffer_2.read(cx).text(), "cd"); + + // A real replacement can apply differently than requested; selection + // remapping should follow the actual edit instead of predicted offsets. + editor.manipulate_text(window, cx, |_| "replacement".to_string()); + assert_eq!(buffer_1.read(cx).text(), "ab"); + assert_eq!(buffer_2.read(cx).text(), ""); + + editor + }); +} + +#[gpui::test] +async fn test_manipulate_text(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + // Test convert_to_upper_case() + cx.set_state(indoc! {" + «hello worldˇ» + "}); + cx.update_editor(|e, window, cx| e.convert_to_upper_case(&ConvertToUpperCase, window, cx)); + cx.assert_editor_state(indoc! {" + «HELLO WORLDˇ» + "}); + + // Test convert_to_lower_case() + cx.set_state(indoc! {" + «HELLO WORLDˇ» + "}); + cx.update_editor(|e, window, cx| e.convert_to_lower_case(&ConvertToLowerCase, window, cx)); + cx.assert_editor_state(indoc! {" + «hello worldˇ» + "}); + + // Test multiple line, single selection case + cx.set_state(indoc! {" + «The quick brown + fox jumps over + the lazy dogˇ» + "}); + cx.update_editor(|e, window, cx| e.convert_to_title_case(&ConvertToTitleCase, window, cx)); + cx.assert_editor_state(indoc! {" + «The Quick Brown + Fox Jumps Over + The Lazy Dogˇ» + "}); + + // Test multiple line, single selection case + cx.set_state(indoc! {" + «The quick brown + fox jumps over + the lazy dogˇ» + "}); + cx.update_editor(|e, window, cx| { + e.convert_to_upper_camel_case(&ConvertToUpperCamelCase, window, cx) + }); + cx.assert_editor_state(indoc! {" + «TheQuickBrown + FoxJumpsOver + TheLazyDogˇ» + "}); + + // From here on out, test more complex cases of manipulate_text() + + // Test no selection case - should affect words cursors are in + // Cursor at beginning, middle, and end of word + cx.set_state(indoc! {" + ˇhello big beauˇtiful worldˇ + "}); + cx.update_editor(|e, window, cx| e.convert_to_upper_case(&ConvertToUpperCase, window, cx)); + cx.assert_editor_state(indoc! {" + «HELLOˇ» big «BEAUTIFULˇ» «WORLDˇ» + "}); + + // Test multiple selections on a single line and across multiple lines + cx.set_state(indoc! {" + «Theˇ» quick «brown + foxˇ» jumps «overˇ» + the «lazyˇ» dog + "}); + cx.update_editor(|e, window, cx| e.convert_to_upper_case(&ConvertToUpperCase, window, cx)); + cx.assert_editor_state(indoc! {" + «THEˇ» quick «BROWN + FOXˇ» jumps «OVERˇ» + the «LAZYˇ» dog + "}); + + // Test case where text length grows + cx.set_state(indoc! {" + «tschüߡ» + "}); + cx.update_editor(|e, window, cx| e.convert_to_upper_case(&ConvertToUpperCase, window, cx)); + cx.assert_editor_state(indoc! {" + «TSCHÜSSˇ» + "}); + + // Test to make sure we don't crash when text shrinks + cx.set_state(indoc! {" + aaa_bbbˇ + "}); + cx.update_editor(|e, window, cx| { + e.convert_to_lower_camel_case(&ConvertToLowerCamelCase, window, cx) + }); + cx.assert_editor_state(indoc! {" + «aaaBbbˇ» + "}); + + // Test to make sure we all aware of the fact that each word can grow and shrink + // Final selections should be aware of this fact + cx.set_state(indoc! {" + aaa_bˇbb bbˇb_ccc ˇccc_ddd + "}); + cx.update_editor(|e, window, cx| { + e.convert_to_lower_camel_case(&ConvertToLowerCamelCase, window, cx) + }); + cx.assert_editor_state(indoc! {" + «aaaBbbˇ» «bbbCccˇ» «cccDddˇ» + "}); + + cx.set_state(indoc! {" + «hElLo, WoRld!ˇ» + "}); + cx.update_editor(|e, window, cx| { + e.convert_to_opposite_case(&ConvertToOppositeCase, window, cx) + }); + cx.assert_editor_state(indoc! {" + «HeLlO, wOrLD!ˇ» + "}); + + // Test that case conversions backed by `to_case` preserve leading/trailing whitespace. + cx.set_state(indoc! {" + « hello worldˇ» + "}); + cx.update_editor(|e, window, cx| e.convert_to_title_case(&ConvertToTitleCase, window, cx)); + cx.assert_editor_state(indoc! {" + « Hello Worldˇ» + "}); + + cx.set_state(indoc! {" + « hello worldˇ» + "}); + cx.update_editor(|e, window, cx| { + e.convert_to_upper_camel_case(&ConvertToUpperCamelCase, window, cx) + }); + cx.assert_editor_state(indoc! {" + « HelloWorldˇ» + "}); + + cx.set_state(indoc! {" + « hello worldˇ» + "}); + cx.update_editor(|e, window, cx| { + e.convert_to_lower_camel_case(&ConvertToLowerCamelCase, window, cx) + }); + cx.assert_editor_state(indoc! {" + « helloWorldˇ» + "}); + + cx.set_state(indoc! {" + « hello worldˇ» + "}); + cx.update_editor(|e, window, cx| e.convert_to_snake_case(&ConvertToSnakeCase, window, cx)); + cx.assert_editor_state(indoc! {" + « hello_worldˇ» + "}); + + cx.set_state(indoc! {" + « hello worldˇ» + "}); + cx.update_editor(|e, window, cx| e.convert_to_kebab_case(&ConvertToKebabCase, window, cx)); + cx.assert_editor_state(indoc! {" + « hello-worldˇ» + "}); + + cx.set_state(indoc! {" + « hello worldˇ» + "}); + cx.update_editor(|e, window, cx| { + e.convert_to_sentence_case(&ConvertToSentenceCase, window, cx) + }); + cx.assert_editor_state(indoc! {" + « Hello worldˇ» + "}); + + cx.set_state(indoc! {" + « hello world\t\tˇ» + "}); + cx.update_editor(|e, window, cx| e.convert_to_title_case(&ConvertToTitleCase, window, cx)); + cx.assert_editor_state(indoc! {" + « Hello World\t\tˇ» + "}); + + cx.set_state(indoc! {" + « hello world\t\tˇ» + "}); + cx.update_editor(|e, window, cx| e.convert_to_snake_case(&ConvertToSnakeCase, window, cx)); + cx.assert_editor_state(indoc! {" + « hello_world\t\tˇ» + "}); + + cx.set_state(indoc! {" + «hello world + ˇ»goodbye + "}); + cx.update_editor(|e, window, cx| e.convert_to_snake_case(&ConvertToSnakeCase, window, cx)); + cx.assert_editor_state(indoc! {" + «hello_world + ˇ»goodbye + "}); + + // Test selections with `line_mode() = true`. + cx.update_editor(|editor, _window, _cx| editor.selections.set_line_mode(true)); + cx.set_state(indoc! {" + «The quick brown + fox jumps over + tˇ»he lazy dog + "}); + cx.update_editor(|e, window, cx| e.convert_to_upper_case(&ConvertToUpperCase, window, cx)); + cx.assert_editor_state(indoc! {" + «THE QUICK BROWN + FOX JUMPS OVER + THE LAZY DOGˇ» + "}); +} + +#[gpui::test] +fn test_duplicate_line(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let editor = cx.add_window(|window, cx| { + let buffer = MultiBuffer::build_simple("abc\ndef\nghi\n", cx); + build_editor(buffer, window, cx) + }); + _ = editor.update(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 1), + DisplayPoint::new(DisplayRow(0), 2)..DisplayPoint::new(DisplayRow(0), 2), + DisplayPoint::new(DisplayRow(1), 0)..DisplayPoint::new(DisplayRow(1), 0), + DisplayPoint::new(DisplayRow(3), 0)..DisplayPoint::new(DisplayRow(3), 0), + ]) + }); + editor.duplicate_line_down(&DuplicateLineDown, window, cx); + assert_eq!(editor.display_text(cx), "abc\nabc\ndef\ndef\nghi\n\n"); + assert_eq!( + display_ranges(editor, cx), + vec![ + DisplayPoint::new(DisplayRow(1), 0)..DisplayPoint::new(DisplayRow(1), 1), + DisplayPoint::new(DisplayRow(1), 2)..DisplayPoint::new(DisplayRow(1), 2), + DisplayPoint::new(DisplayRow(3), 0)..DisplayPoint::new(DisplayRow(3), 0), + DisplayPoint::new(DisplayRow(6), 0)..DisplayPoint::new(DisplayRow(6), 0), + ] + ); + }); + + let editor = cx.add_window(|window, cx| { + let buffer = MultiBuffer::build_simple("abc\ndef\nghi\n", cx); + build_editor(buffer, window, cx) + }); + _ = editor.update(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(0), 1)..DisplayPoint::new(DisplayRow(1), 1), + DisplayPoint::new(DisplayRow(1), 2)..DisplayPoint::new(DisplayRow(2), 1), + ]) + }); + editor.duplicate_line_down(&DuplicateLineDown, window, cx); + assert_eq!(editor.display_text(cx), "abc\ndef\nghi\nabc\ndef\nghi\n"); + assert_eq!( + display_ranges(editor, cx), + vec![ + DisplayPoint::new(DisplayRow(3), 1)..DisplayPoint::new(DisplayRow(4), 1), + DisplayPoint::new(DisplayRow(4), 2)..DisplayPoint::new(DisplayRow(5), 1), + ] + ); + }); + + // With `duplicate_line_up` the selections move to the duplicated lines, + // which are inserted above the original lines + let editor = cx.add_window(|window, cx| { + let buffer = MultiBuffer::build_simple("abc\ndef\nghi\n", cx); + build_editor(buffer, window, cx) + }); + _ = editor.update(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 1), + DisplayPoint::new(DisplayRow(0), 2)..DisplayPoint::new(DisplayRow(0), 2), + DisplayPoint::new(DisplayRow(1), 0)..DisplayPoint::new(DisplayRow(1), 0), + DisplayPoint::new(DisplayRow(3), 0)..DisplayPoint::new(DisplayRow(3), 0), + ]) + }); + editor.duplicate_line_up(&DuplicateLineUp, window, cx); + assert_eq!(editor.display_text(cx), "abc\nabc\ndef\ndef\nghi\n\n"); + assert_eq!( + display_ranges(editor, cx), + vec![ + DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 1), + DisplayPoint::new(DisplayRow(0), 2)..DisplayPoint::new(DisplayRow(0), 2), + DisplayPoint::new(DisplayRow(2), 0)..DisplayPoint::new(DisplayRow(2), 0), + DisplayPoint::new(DisplayRow(5), 0)..DisplayPoint::new(DisplayRow(5), 0), + ] + ); + }); + + let editor = cx.add_window(|window, cx| { + let buffer = MultiBuffer::build_simple("abc\ndef\nghi\n", cx); + build_editor(buffer, window, cx) + }); + _ = editor.update(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(0), 1)..DisplayPoint::new(DisplayRow(1), 1), + DisplayPoint::new(DisplayRow(1), 2)..DisplayPoint::new(DisplayRow(2), 1), + ]) + }); + editor.duplicate_line_up(&DuplicateLineUp, window, cx); + assert_eq!(editor.display_text(cx), "abc\ndef\nghi\nabc\ndef\nghi\n"); + assert_eq!( + display_ranges(editor, cx), + vec![ + DisplayPoint::new(DisplayRow(0), 1)..DisplayPoint::new(DisplayRow(1), 1), + DisplayPoint::new(DisplayRow(1), 2)..DisplayPoint::new(DisplayRow(2), 1), + ] + ); + }); + + let editor = cx.add_window(|window, cx| { + let buffer = MultiBuffer::build_simple("abc\ndef\nghi\n", cx); + build_editor(buffer, window, cx) + }); + _ = editor.update(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(0), 1)..DisplayPoint::new(DisplayRow(1), 1), + DisplayPoint::new(DisplayRow(1), 2)..DisplayPoint::new(DisplayRow(2), 1), + ]) + }); + editor.duplicate_selection(&DuplicateSelection, window, cx); + assert_eq!(editor.display_text(cx), "abc\ndbc\ndef\ngf\nghi\n"); + assert_eq!( + display_ranges(editor, cx), + vec![ + DisplayPoint::new(DisplayRow(0), 1)..DisplayPoint::new(DisplayRow(1), 1), + DisplayPoint::new(DisplayRow(2), 2)..DisplayPoint::new(DisplayRow(3), 1), + ] + ); + }); +} + +#[gpui::test] +async fn test_rotate_selections(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + // Rotate text selections (horizontal) + cx.set_state("x=«1ˇ», y=«2ˇ», z=«3ˇ»"); + cx.update_editor(|e, window, cx| { + e.rotate_selections_forward(&RotateSelectionsForward, window, cx) + }); + cx.assert_editor_state("x=«3ˇ», y=«1ˇ», z=«2ˇ»"); + cx.update_editor(|e, window, cx| { + e.rotate_selections_backward(&RotateSelectionsBackward, window, cx) + }); + cx.assert_editor_state("x=«1ˇ», y=«2ˇ», z=«3ˇ»"); + + // Rotate text selections (vertical) + cx.set_state(indoc! {" + x=«1ˇ» + y=«2ˇ» + z=«3ˇ» + "}); + cx.update_editor(|e, window, cx| { + e.rotate_selections_forward(&RotateSelectionsForward, window, cx) + }); + cx.assert_editor_state(indoc! {" + x=«3ˇ» + y=«1ˇ» + z=«2ˇ» + "}); + cx.update_editor(|e, window, cx| { + e.rotate_selections_backward(&RotateSelectionsBackward, window, cx) + }); + cx.assert_editor_state(indoc! {" + x=«1ˇ» + y=«2ˇ» + z=«3ˇ» + "}); + + // Rotate text selections (vertical, different lengths) + cx.set_state(indoc! {" + x=\"«ˇ»\" + y=\"«aˇ»\" + z=\"«aaˇ»\" + "}); + cx.update_editor(|e, window, cx| { + e.rotate_selections_forward(&RotateSelectionsForward, window, cx) + }); + cx.assert_editor_state(indoc! {" + x=\"«aaˇ»\" + y=\"«ˇ»\" + z=\"«aˇ»\" + "}); + cx.update_editor(|e, window, cx| { + e.rotate_selections_backward(&RotateSelectionsBackward, window, cx) + }); + cx.assert_editor_state(indoc! {" + x=\"«ˇ»\" + y=\"«aˇ»\" + z=\"«aaˇ»\" + "}); + + // Rotate whole lines (cursor positions preserved) + cx.set_state(indoc! {" + ˇline123 + liˇne23 + line3ˇ + "}); + cx.update_editor(|e, window, cx| { + e.rotate_selections_forward(&RotateSelectionsForward, window, cx) + }); + cx.assert_editor_state(indoc! {" + line3ˇ + ˇline123 + liˇne23 + "}); + cx.update_editor(|e, window, cx| { + e.rotate_selections_backward(&RotateSelectionsBackward, window, cx) + }); + cx.assert_editor_state(indoc! {" + ˇline123 + liˇne23 + line3ˇ + "}); + + // Rotate whole lines, multiple cursors per line (positions preserved) + cx.set_state(indoc! {" + ˇliˇne123 + ˇline23 + ˇline3 + "}); + cx.update_editor(|e, window, cx| { + e.rotate_selections_forward(&RotateSelectionsForward, window, cx) + }); + cx.assert_editor_state(indoc! {" + ˇline3 + ˇliˇne123 + ˇline23 + "}); + cx.update_editor(|e, window, cx| { + e.rotate_selections_backward(&RotateSelectionsBackward, window, cx) + }); + cx.assert_editor_state(indoc! {" + ˇliˇne123 + ˇline23 + ˇline3 + "}); +} + +#[gpui::test] +async fn test_rotate_selections_nonconsecutive_lines(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + cx.set_state(indoc! {" + ˇline1 + line2 + liˇne3 + line4ˇ + "}); + + cx.update_editor(|e, window, cx| { + e.rotate_selections_forward(&RotateSelectionsForward, window, cx) + }); + cx.assert_editor_state(indoc! {" + line4ˇ + line2 + ˇline1 + liˇne3 + "}); + + cx.update_editor(|e, window, cx| { + e.rotate_selections_backward(&RotateSelectionsBackward, window, cx) + }); + cx.assert_editor_state(indoc! {" + ˇline1 + line2 + liˇne3 + line4ˇ + "}); +} + +#[gpui::test] +fn test_move_line_up_down(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let editor = cx.add_window(|window, cx| { + let buffer = MultiBuffer::build_simple(&sample_text(10, 5, 'a'), cx); + build_editor(buffer, window, cx) + }); + _ = editor.update(cx, |editor, window, cx| { + editor.fold_creases( + vec![ + Crease::simple(Point::new(0, 2)..Point::new(1, 2), FoldPlaceholder::test()), + Crease::simple(Point::new(2, 3)..Point::new(4, 1), FoldPlaceholder::test()), + Crease::simple(Point::new(7, 0)..Point::new(8, 4), FoldPlaceholder::test()), + ], + true, + window, + cx, + ); + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(0), 1)..DisplayPoint::new(DisplayRow(0), 1), + DisplayPoint::new(DisplayRow(3), 1)..DisplayPoint::new(DisplayRow(3), 1), + DisplayPoint::new(DisplayRow(3), 2)..DisplayPoint::new(DisplayRow(4), 3), + DisplayPoint::new(DisplayRow(5), 0)..DisplayPoint::new(DisplayRow(5), 2), + ]) + }); + assert_eq!( + editor.display_text(cx), + "aa⋯bbb\nccc⋯eeee\nfffff\nggggg\n⋯i\njjjjj" + ); + + editor.move_line_up(&MoveLineUp, window, cx); + assert_eq!( + editor.display_text(cx), + "aa⋯bbb\nccc⋯eeee\nggggg\n⋯i\njjjjj\nfffff" + ); + assert_eq!( + display_ranges(editor, cx), + vec![ + DisplayPoint::new(DisplayRow(0), 1)..DisplayPoint::new(DisplayRow(0), 1), + DisplayPoint::new(DisplayRow(2), 1)..DisplayPoint::new(DisplayRow(2), 1), + DisplayPoint::new(DisplayRow(2), 2)..DisplayPoint::new(DisplayRow(3), 3), + DisplayPoint::new(DisplayRow(4), 0)..DisplayPoint::new(DisplayRow(4), 2) + ] + ); + }); + + _ = editor.update(cx, |editor, window, cx| { + editor.move_line_down(&MoveLineDown, window, cx); + assert_eq!( + editor.display_text(cx), + "ccc⋯eeee\naa⋯bbb\nfffff\nggggg\n⋯i\njjjjj" + ); + assert_eq!( + display_ranges(editor, cx), + vec![ + DisplayPoint::new(DisplayRow(1), 1)..DisplayPoint::new(DisplayRow(1), 1), + DisplayPoint::new(DisplayRow(3), 1)..DisplayPoint::new(DisplayRow(3), 1), + DisplayPoint::new(DisplayRow(3), 2)..DisplayPoint::new(DisplayRow(4), 3), + DisplayPoint::new(DisplayRow(5), 0)..DisplayPoint::new(DisplayRow(5), 2) + ] + ); + }); + + _ = editor.update(cx, |editor, window, cx| { + editor.move_line_down(&MoveLineDown, window, cx); + assert_eq!( + editor.display_text(cx), + "ccc⋯eeee\nfffff\naa⋯bbb\nggggg\n⋯i\njjjjj" + ); + assert_eq!( + display_ranges(editor, cx), + vec![ + DisplayPoint::new(DisplayRow(2), 1)..DisplayPoint::new(DisplayRow(2), 1), + DisplayPoint::new(DisplayRow(3), 1)..DisplayPoint::new(DisplayRow(3), 1), + DisplayPoint::new(DisplayRow(3), 2)..DisplayPoint::new(DisplayRow(4), 3), + DisplayPoint::new(DisplayRow(5), 0)..DisplayPoint::new(DisplayRow(5), 2) + ] + ); + }); + + _ = editor.update(cx, |editor, window, cx| { + editor.move_line_up(&MoveLineUp, window, cx); + assert_eq!( + editor.display_text(cx), + "ccc⋯eeee\naa⋯bbb\nggggg\n⋯i\njjjjj\nfffff" + ); + assert_eq!( + display_ranges(editor, cx), + vec![ + DisplayPoint::new(DisplayRow(1), 1)..DisplayPoint::new(DisplayRow(1), 1), + DisplayPoint::new(DisplayRow(2), 1)..DisplayPoint::new(DisplayRow(2), 1), + DisplayPoint::new(DisplayRow(2), 2)..DisplayPoint::new(DisplayRow(3), 3), + DisplayPoint::new(DisplayRow(4), 0)..DisplayPoint::new(DisplayRow(4), 2) + ] + ); + }); +} + +#[gpui::test] +fn test_move_line_up_selection_at_end_of_fold(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let editor = cx.add_window(|window, cx| { + let buffer = MultiBuffer::build_simple("\n\n\n\n\n\naaaa\nbbbb\ncccc", cx); + build_editor(buffer, window, cx) + }); + _ = editor.update(cx, |editor, window, cx| { + editor.fold_creases( + vec![Crease::simple( + Point::new(6, 4)..Point::new(7, 4), + FoldPlaceholder::test(), + )], + true, + window, + cx, + ); + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_ranges([Point::new(7, 4)..Point::new(7, 4)]) + }); + assert_eq!(editor.display_text(cx), "\n\n\n\n\n\naaaa⋯\ncccc"); + editor.move_line_up(&MoveLineUp, window, cx); + let buffer_text = editor.buffer.read(cx).snapshot(cx).text(); + assert_eq!(buffer_text, "\n\n\n\n\naaaa\nbbbb\n\ncccc"); + }); +} + +#[gpui::test] +fn test_move_line_up_down_with_blocks(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let editor = cx.add_window(|window, cx| { + let buffer = MultiBuffer::build_simple(&sample_text(10, 5, 'a'), cx); + build_editor(buffer, window, cx) + }); + _ = editor.update(cx, |editor, window, cx| { + let snapshot = editor.buffer.read(cx).snapshot(cx); + editor.insert_blocks( + [BlockProperties { + style: BlockStyle::Fixed, + placement: BlockPlacement::Below(snapshot.anchor_after(Point::new(2, 0))), + height: Some(1), + render: Arc::new(|_| div().into_any()), + priority: 0, + }], + Some(Autoscroll::fit()), + cx, + ); + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_ranges([Point::new(2, 0)..Point::new(2, 0)]) + }); + editor.move_line_down(&MoveLineDown, window, cx); + }); +} + +#[gpui::test] +async fn test_selections_and_replace_blocks(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + cx.set_state( + &" + ˇzero + one + two + three + four + five + " + .unindent(), + ); + + // Create a four-line block that replaces three lines of text. + cx.update_editor(|editor, window, cx| { + let snapshot = editor.snapshot(window, cx); + let snapshot = &snapshot.buffer_snapshot(); + let placement = BlockPlacement::Replace( + snapshot.anchor_after(Point::new(1, 0))..=snapshot.anchor_after(Point::new(3, 0)), + ); + editor.insert_blocks( + [BlockProperties { + placement, + height: Some(4), + style: BlockStyle::Sticky, + render: Arc::new(|_| gpui::div().into_any_element()), + priority: 0, + }], + None, + cx, + ); + }); + + // Move down so that the cursor touches the block. + cx.update_editor(|editor, window, cx| { + editor.move_down(&Default::default(), window, cx); + }); + cx.assert_editor_state( + &" + zero + «one + two + threeˇ» + four + five + " + .unindent(), + ); + + // Move down past the block. + cx.update_editor(|editor, window, cx| { + editor.move_down(&Default::default(), window, cx); + }); + cx.assert_editor_state( + &" + zero + one + two + three + ˇfour + five + " + .unindent(), + ); +} + +#[gpui::test] +fn test_transpose(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + _ = cx.add_window(|window, cx| { + let mut editor = build_editor(MultiBuffer::build_simple("abc", cx), window, cx); + editor.set_style(EditorStyle::default(), window, cx); + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_ranges([MultiBufferOffset(1)..MultiBufferOffset(1)]) + }); + editor.transpose(&Default::default(), window, cx); + assert_eq!(editor.text(cx), "bac"); + assert_eq!( + editor.selections.ranges(&editor.display_snapshot(cx)), + [MultiBufferOffset(2)..MultiBufferOffset(2)] + ); + + editor.transpose(&Default::default(), window, cx); + assert_eq!(editor.text(cx), "bca"); + assert_eq!( + editor.selections.ranges(&editor.display_snapshot(cx)), + [MultiBufferOffset(3)..MultiBufferOffset(3)] + ); + + editor.transpose(&Default::default(), window, cx); + assert_eq!(editor.text(cx), "bac"); + assert_eq!( + editor.selections.ranges(&editor.display_snapshot(cx)), + [MultiBufferOffset(3)..MultiBufferOffset(3)] + ); + + editor + }); + + _ = cx.add_window(|window, cx| { + let mut editor = build_editor(MultiBuffer::build_simple("abc\nde", cx), window, cx); + editor.set_style(EditorStyle::default(), window, cx); + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_ranges([MultiBufferOffset(3)..MultiBufferOffset(3)]) + }); + editor.transpose(&Default::default(), window, cx); + assert_eq!(editor.text(cx), "acb\nde"); + assert_eq!( + editor.selections.ranges(&editor.display_snapshot(cx)), + [MultiBufferOffset(3)..MultiBufferOffset(3)] + ); + + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_ranges([MultiBufferOffset(4)..MultiBufferOffset(4)]) + }); + editor.transpose(&Default::default(), window, cx); + assert_eq!(editor.text(cx), "acbd\ne"); + assert_eq!( + editor.selections.ranges(&editor.display_snapshot(cx)), + [MultiBufferOffset(5)..MultiBufferOffset(5)] + ); + + editor.transpose(&Default::default(), window, cx); + assert_eq!(editor.text(cx), "acbde\n"); + assert_eq!( + editor.selections.ranges(&editor.display_snapshot(cx)), + [MultiBufferOffset(6)..MultiBufferOffset(6)] + ); + + editor.transpose(&Default::default(), window, cx); + assert_eq!(editor.text(cx), "acbd\ne"); + assert_eq!( + editor.selections.ranges(&editor.display_snapshot(cx)), + [MultiBufferOffset(6)..MultiBufferOffset(6)] + ); + + editor + }); + + _ = cx.add_window(|window, cx| { + let mut editor = build_editor(MultiBuffer::build_simple("abc\nde", cx), window, cx); + editor.set_style(EditorStyle::default(), window, cx); + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_ranges([ + MultiBufferOffset(1)..MultiBufferOffset(1), + MultiBufferOffset(2)..MultiBufferOffset(2), + MultiBufferOffset(4)..MultiBufferOffset(4), + ]) + }); + editor.transpose(&Default::default(), window, cx); + assert_eq!(editor.text(cx), "bacd\ne"); + assert_eq!( + editor.selections.ranges(&editor.display_snapshot(cx)), + [ + MultiBufferOffset(2)..MultiBufferOffset(2), + MultiBufferOffset(3)..MultiBufferOffset(3), + MultiBufferOffset(5)..MultiBufferOffset(5) + ] + ); + + editor.transpose(&Default::default(), window, cx); + assert_eq!(editor.text(cx), "bcade\n"); + assert_eq!( + editor.selections.ranges(&editor.display_snapshot(cx)), + [ + MultiBufferOffset(3)..MultiBufferOffset(3), + MultiBufferOffset(4)..MultiBufferOffset(4), + MultiBufferOffset(6)..MultiBufferOffset(6) + ] + ); + + editor.transpose(&Default::default(), window, cx); + assert_eq!(editor.text(cx), "bcda\ne"); + assert_eq!( + editor.selections.ranges(&editor.display_snapshot(cx)), + [ + MultiBufferOffset(4)..MultiBufferOffset(4), + MultiBufferOffset(6)..MultiBufferOffset(6) + ] + ); + + editor.transpose(&Default::default(), window, cx); + assert_eq!(editor.text(cx), "bcade\n"); + assert_eq!( + editor.selections.ranges(&editor.display_snapshot(cx)), + [ + MultiBufferOffset(4)..MultiBufferOffset(4), + MultiBufferOffset(6)..MultiBufferOffset(6) + ] + ); + + editor.transpose(&Default::default(), window, cx); + assert_eq!(editor.text(cx), "bcaed\n"); + assert_eq!( + editor.selections.ranges(&editor.display_snapshot(cx)), + [ + MultiBufferOffset(5)..MultiBufferOffset(5), + MultiBufferOffset(6)..MultiBufferOffset(6) + ] + ); + + editor + }); + + _ = cx.add_window(|window, cx| { + let mut editor = build_editor(MultiBuffer::build_simple("🍐🏀✋", cx), window, cx); + editor.set_style(EditorStyle::default(), window, cx); + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_ranges([MultiBufferOffset(4)..MultiBufferOffset(4)]) + }); + editor.transpose(&Default::default(), window, cx); + assert_eq!(editor.text(cx), "🏀🍐✋"); + assert_eq!( + editor.selections.ranges(&editor.display_snapshot(cx)), + [MultiBufferOffset(8)..MultiBufferOffset(8)] + ); + + editor.transpose(&Default::default(), window, cx); + assert_eq!(editor.text(cx), "🏀✋🍐"); + assert_eq!( + editor.selections.ranges(&editor.display_snapshot(cx)), + [MultiBufferOffset(11)..MultiBufferOffset(11)] + ); + + editor.transpose(&Default::default(), window, cx); + assert_eq!(editor.text(cx), "🏀🍐✋"); + assert_eq!( + editor.selections.ranges(&editor.display_snapshot(cx)), + [MultiBufferOffset(11)..MultiBufferOffset(11)] + ); + + editor + }); +} + +#[gpui::test] +async fn test_rewrap(cx: &mut TestAppContext) { + init_test(cx, |settings| { + settings.languages.0.extend([ + ( + "Markdown".into(), + LanguageSettingsContent { + allow_rewrap: Some(language_settings::RewrapBehavior::Anywhere), + preferred_line_length: Some(40), + ..Default::default() + }, + ), + ( + "Plain Text".into(), + LanguageSettingsContent { + allow_rewrap: Some(language_settings::RewrapBehavior::Anywhere), + preferred_line_length: Some(40), + ..Default::default() + }, + ), + ( + "C++".into(), + LanguageSettingsContent { + allow_rewrap: Some(language_settings::RewrapBehavior::InComments), + preferred_line_length: Some(40), + ..Default::default() + }, + ), + ( + "Python".into(), + LanguageSettingsContent { + allow_rewrap: Some(language_settings::RewrapBehavior::InComments), + preferred_line_length: Some(40), + ..Default::default() + }, + ), + ( + "Rust".into(), + LanguageSettingsContent { + allow_rewrap: Some(language_settings::RewrapBehavior::InComments), + preferred_line_length: Some(40), + ..Default::default() + }, + ), + ]) + }); + + let mut cx = EditorTestContext::new(cx).await; + + let cpp_language = Arc::new(Language::new( + LanguageConfig { + name: "C++".into(), + line_comments: vec!["// ".into()], + ..LanguageConfig::default() + }, + None, + )); + let python_language = Arc::new(Language::new( + LanguageConfig { + name: "Python".into(), + line_comments: vec!["# ".into()], + ..LanguageConfig::default() + }, + None, + )); + let markdown_language = Arc::new(Language::new( + LanguageConfig { + name: "Markdown".into(), + rewrap_prefixes: vec![ + regex::Regex::new("\\d+\\.\\s+").unwrap(), + regex::Regex::new("[-*+]\\s+").unwrap(), + ], + ..LanguageConfig::default() + }, + None, + )); + let rust_language = Arc::new( + Language::new( + LanguageConfig { + name: "Rust".into(), + line_comments: vec!["// ".into(), "/// ".into()], + ..LanguageConfig::default() + }, + Some(tree_sitter_rust::LANGUAGE.into()), + ) + .with_override_query("[(line_comment)(block_comment)] @comment.inclusive") + .unwrap(), + ); + + let plaintext_language = Arc::new(Language::new( + LanguageConfig { + name: "Plain Text".into(), + ..LanguageConfig::default() + }, + None, + )); + + // Test basic rewrapping of a long line with a cursor + assert_rewrap( + indoc! {" + // ˇThis is a long comment that needs to be wrapped. + "}, + indoc! {" + // ˇThis is a long comment that needs to + // be wrapped. + "}, + cpp_language.clone(), + &mut cx, + ); + + // Test rewrapping a full selection + assert_rewrap( + indoc! {" + «// This selected long comment needs to be wrapped.ˇ»" + }, + indoc! {" + «// This selected long comment needs to + // be wrapped.ˇ»" + }, + cpp_language.clone(), + &mut cx, + ); + + // Test multiple cursors on different lines within the same paragraph are preserved after rewrapping + assert_rewrap( + indoc! {" + // ˇThis is the first line. + // Thisˇ is the second line. + // This is the thirdˇ line, all part of one paragraph. + "}, + indoc! {" + // ˇThis is the first line. Thisˇ is the + // second line. This is the thirdˇ line, + // all part of one paragraph. + "}, + cpp_language.clone(), + &mut cx, + ); + + // Test multiple cursors in different paragraphs trigger separate rewraps + assert_rewrap( + indoc! {" + // ˇThis is the first paragraph, first line. + // ˇThis is the first paragraph, second line. + + // ˇThis is the second paragraph, first line. + // ˇThis is the second paragraph, second line. + "}, + indoc! {" + // ˇThis is the first paragraph, first + // line. ˇThis is the first paragraph, + // second line. + + // ˇThis is the second paragraph, first + // line. ˇThis is the second paragraph, + // second line. + "}, + cpp_language.clone(), + &mut cx, + ); + + // Test that change in comment prefix (e.g., `//` to `///`) trigger separate rewraps + assert_rewrap( + indoc! {" + «// A regular long long comment to be wrapped. + /// A documentation long comment to be wrapped.ˇ» + "}, + indoc! {" + «// A regular long long comment to be + // wrapped. + /// A documentation long comment to be + /// wrapped.ˇ» + "}, + rust_language.clone(), + &mut cx, + ); + + // Test that change in indentation level trigger separate rewraps + assert_rewrap( + indoc! {" + fn foo() { + «// This is a long comment at the base indent. + // This is a long comment at the next indent.ˇ» + } + "}, + indoc! {" + fn foo() { + «// This is a long comment at the + // base indent. + // This is a long comment at the + // next indent.ˇ» + } + "}, + rust_language.clone(), + &mut cx, + ); + + // Test that different comment prefix characters (e.g., '#') are handled correctly + assert_rewrap( + indoc! {" + # ˇThis is a long comment using a pound sign. + "}, + indoc! {" + # ˇThis is a long comment using a pound + # sign. + "}, + python_language, + &mut cx, + ); + + // Test rewrapping only affects comments, not code even when selected + assert_rewrap( + indoc! {" + «/// This doc comment is long and should be wrapped. + fn my_func(a: u32, b: u32, c: u32, d: u32, e: u32, f: u32) {}ˇ» + "}, + indoc! {" + «/// This doc comment is long and should + /// be wrapped. + fn my_func(a: u32, b: u32, c: u32, d: u32, e: u32, f: u32) {}ˇ» + "}, + rust_language.clone(), + &mut cx, + ); + + // Test that rewrapping works in Markdown documents where `allow_rewrap` is `Anywhere` + assert_rewrap( + indoc! {" + # Header + + A long long long line of markdown text to wrap.ˇ + "}, + indoc! {" + # Header + + A long long long line of markdown text + to wrap.ˇ + "}, + markdown_language.clone(), + &mut cx, + ); + + // Test that rewrapping boundary works and preserves relative indent for Markdown documents + assert_rewrap( + indoc! {" + «1. This is a numbered list item that is very long and needs to be wrapped properly. + 2. This is a numbered list item that is very long and needs to be wrapped properly. + - This is an unordered list item that is also very long and should not merge with the numbered item.ˇ» + "}, + indoc! {" + «1. This is a numbered list item that is + very long and needs to be wrapped + properly. + 2. This is a numbered list item that is + very long and needs to be wrapped + properly. + - This is an unordered list item that is + also very long and should not merge + with the numbered item.ˇ» + "}, + markdown_language.clone(), + &mut cx, + ); + + // Test that rewrapping add indents for rewrapping boundary if not exists already. + assert_rewrap( + indoc! {" + «1. This is a numbered list item that is + very long and needs to be wrapped + properly. + 2. This is a numbered list item that is + very long and needs to be wrapped + properly. + - This is an unordered list item that is + also very long and should not merge with + the numbered item.ˇ» + "}, + indoc! {" + «1. This is a numbered list item that is + very long and needs to be wrapped + properly. + 2. This is a numbered list item that is + very long and needs to be wrapped + properly. + - This is an unordered list item that is + also very long and should not merge + with the numbered item.ˇ» + "}, + markdown_language.clone(), + &mut cx, + ); + + // Test that rewrapping maintain indents even when they already exists. + assert_rewrap( + indoc! {" + «1. This is a numbered list + item that is very long and needs to be wrapped properly. + 2. This is a numbered list + item that is very long and needs to be wrapped properly. + - This is an unordered list item that is also very long and + should not merge with the numbered item.ˇ» + "}, + indoc! {" + «1. This is a numbered list item that is + very long and needs to be wrapped + properly. + 2. This is a numbered list item that is + very long and needs to be wrapped + properly. + - This is an unordered list item that is + also very long and should not merge + with the numbered item.ˇ» + "}, + markdown_language.clone(), + &mut cx, + ); + + // Test that empty selection rewrap on a numbered list item does not merge adjacent items + assert_rewrap( + indoc! {" + 1. This is the first numbered list item that is very long and needs to be wrapped properly. + 2. ˇThis is the second numbered list item that is also very long and needs to be wrapped. + 3. This is the third numbered list item, shorter. + "}, + indoc! {" + 1. This is the first numbered list item + that is very long and needs to be + wrapped properly. + 2. ˇThis is the second numbered list item + that is also very long and needs to + be wrapped. + 3. This is the third numbered list item, + shorter. + "}, + markdown_language.clone(), + &mut cx, + ); + + // Test that empty selection rewrap on a bullet list item does not merge adjacent items + assert_rewrap( + indoc! {" + - This is the first bullet item that is very long and needs wrapping properly here. + - ˇThis is the second bullet item that is also very long and needs to be wrapped. + - This is the third bullet item, shorter. + "}, + indoc! {" + - This is the first bullet item that is + very long and needs wrapping properly + here. + - ˇThis is the second bullet item that is + also very long and needs to be + wrapped. + - This is the third bullet item, + shorter. + "}, + markdown_language, + &mut cx, + ); + + // Test that rewrapping works in plain text where `allow_rewrap` is `Anywhere` + assert_rewrap( + indoc! {" + ˇThis is a very long line of plain text that will be wrapped. + "}, + indoc! {" + ˇThis is a very long line of plain text + that will be wrapped. + "}, + plaintext_language.clone(), + &mut cx, + ); + + // Test that non-commented code acts as a paragraph boundary within a selection + assert_rewrap( + indoc! {" + «// This is the first long comment block to be wrapped. + fn my_func(a: u32); + // This is the second long comment block to be wrapped.ˇ» + "}, + indoc! {" + «// This is the first long comment block + // to be wrapped. + fn my_func(a: u32); + // This is the second long comment block + // to be wrapped.ˇ» + "}, + rust_language, + &mut cx, + ); + + // Test rewrapping multiple selections, including ones with blank lines or tabs + assert_rewrap( + indoc! {" + «ˇThis is a very long line that will be wrapped. + + This is another paragraph in the same selection.» + + «\tThis is a very long indented line that will be wrapped.ˇ» + "}, + indoc! {" + «ˇThis is a very long line that will be + wrapped. + + This is another paragraph in the same + selection.» + + «\tThis is a very long indented line + \tthat will be wrapped.ˇ» + "}, + plaintext_language, + &mut cx, + ); + + // Test that an empty comment line acts as a paragraph boundary + assert_rewrap( + indoc! {" + // ˇThis is a long comment that will be wrapped. + // + // And this is another long comment that will also be wrapped.ˇ + "}, + indoc! {" + // ˇThis is a long comment that will be + // wrapped. + // + // And this is another long comment that + // will also be wrapped.ˇ + "}, + cpp_language, + &mut cx, + ); + + #[track_caller] + fn assert_rewrap( + unwrapped_text: &str, + wrapped_text: &str, + language: Arc, + cx: &mut EditorTestContext, + ) { + cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); + cx.set_state(unwrapped_text); + cx.update_editor(|e, _, cx| e.rewrap(RewrapOptions::default(), cx)); + cx.assert_editor_state(wrapped_text); + } +} + +#[gpui::test] +async fn test_rewrap_block_comments(cx: &mut TestAppContext) { + init_test(cx, |settings| { + settings.languages.0.extend([( + "Rust".into(), + LanguageSettingsContent { + allow_rewrap: Some(language_settings::RewrapBehavior::InComments), + preferred_line_length: Some(40), + ..Default::default() + }, + )]) + }); + + let mut cx = EditorTestContext::new(cx).await; + + let rust_lang = Arc::new( + Language::new( + LanguageConfig { + name: "Rust".into(), + line_comments: vec!["// ".into()], + block_comment: Some(BlockCommentConfig { + start: "/*".into(), + end: "*/".into(), + prefix: "* ".into(), + tab_size: 1, + }), + documentation_comment: Some(BlockCommentConfig { + start: "/**".into(), + end: "*/".into(), + prefix: "* ".into(), + tab_size: 1, + }), + + ..LanguageConfig::default() + }, + Some(tree_sitter_rust::LANGUAGE.into()), + ) + .with_override_query("[(line_comment) (block_comment)] @comment.inclusive") + .unwrap(), + ); + + // regular block comment + assert_rewrap( + indoc! {" + /* + *ˇ Lorem ipsum dolor sit amet, consectetur adipiscing elit. + */ + /*ˇ Lorem ipsum dolor sit amet, consectetur adipiscing elit. */ + "}, + indoc! {" + /* + *ˇ Lorem ipsum dolor sit amet, + * consectetur adipiscing elit. + */ + /* + *ˇ Lorem ipsum dolor sit amet, + * consectetur adipiscing elit. + */ + "}, + rust_lang.clone(), + &mut cx, + ); + + // indent is respected + assert_rewrap( + indoc! {" + {} + /*ˇ Lorem ipsum dolor sit amet, consectetur adipiscing elit. */ + "}, + indoc! {" + {} + /* + *ˇ Lorem ipsum dolor sit amet, + * consectetur adipiscing elit. + */ + "}, + rust_lang.clone(), + &mut cx, + ); + + // short block comments with inline delimiters + assert_rewrap( + indoc! {" + /*ˇ Lorem ipsum dolor sit amet, consectetur adipiscing elit. */ + /*ˇ Lorem ipsum dolor sit amet, consectetur adipiscing elit. + */ + /* + *ˇ Lorem ipsum dolor sit amet, consectetur adipiscing elit. */ + "}, + indoc! {" + /* + *ˇ Lorem ipsum dolor sit amet, + * consectetur adipiscing elit. + */ + /* + *ˇ Lorem ipsum dolor sit amet, + * consectetur adipiscing elit. + */ + /* + *ˇ Lorem ipsum dolor sit amet, + * consectetur adipiscing elit. + */ + "}, + rust_lang.clone(), + &mut cx, + ); + + // multiline block comment with inline start/end delimiters + assert_rewrap( + indoc! {" + /*ˇ Lorem ipsum dolor sit amet, + * consectetur adipiscing elit. */ + "}, + indoc! {" + /* + *ˇ Lorem ipsum dolor sit amet, + * consectetur adipiscing elit. + */ + "}, + rust_lang.clone(), + &mut cx, + ); + + // block comment rewrap still respects paragraph bounds + assert_rewrap( + indoc! {" + /* + *ˇ Lorem ipsum dolor sit amet, consectetur adipiscing elit. + * + * Lorem ipsum dolor sit amet, consectetur adipiscing elit. + */ + "}, + indoc! {" + /* + *ˇ Lorem ipsum dolor sit amet, + * consectetur adipiscing elit. + * + * Lorem ipsum dolor sit amet, consectetur adipiscing elit. + */ + "}, + rust_lang.clone(), + &mut cx, + ); + + // documentation comments + assert_rewrap( + indoc! {" + /**ˇ Lorem ipsum dolor sit amet, consectetur adipiscing elit. */ + /** + *ˇ Lorem ipsum dolor sit amet, consectetur adipiscing elit. + */ + "}, + indoc! {" + /** + *ˇ Lorem ipsum dolor sit amet, + * consectetur adipiscing elit. + */ + /** + *ˇ Lorem ipsum dolor sit amet, + * consectetur adipiscing elit. + */ + "}, + rust_lang.clone(), + &mut cx, + ); + + // different, adjacent comments + assert_rewrap( + indoc! {" + /** + *ˇ Lorem ipsum dolor sit amet, consectetur adipiscing elit. + */ + /*ˇ Lorem ipsum dolor sit amet, consectetur adipiscing elit. */ + //ˇ Lorem ipsum dolor sit amet, consectetur adipiscing elit. + "}, + indoc! {" + /** + *ˇ Lorem ipsum dolor sit amet, + * consectetur adipiscing elit. + */ + /* + *ˇ Lorem ipsum dolor sit amet, + * consectetur adipiscing elit. + */ + //ˇ Lorem ipsum dolor sit amet, + // consectetur adipiscing elit. + "}, + rust_lang.clone(), + &mut cx, + ); + + // selection w/ single short block comment + assert_rewrap( + indoc! {" + «/* Lorem ipsum dolor sit amet, consectetur adipiscing elit. */ˇ» + "}, + indoc! {" + «/* + * Lorem ipsum dolor sit amet, + * consectetur adipiscing elit. + */ˇ» + "}, + rust_lang.clone(), + &mut cx, + ); + + // rewrapping a single comment w/ abutting comments + assert_rewrap( + indoc! {" + /* ˇLorem ipsum dolor sit amet, consectetur adipiscing elit. */ + /* Lorem ipsum dolor sit amet, consectetur adipiscing elit. */ + "}, + indoc! {" + /* + * ˇLorem ipsum dolor sit amet, + * consectetur adipiscing elit. + */ + /* Lorem ipsum dolor sit amet, consectetur adipiscing elit. */ + "}, + rust_lang.clone(), + &mut cx, + ); + + // selection w/ non-abutting short block comments + assert_rewrap( + indoc! {" + «/* Lorem ipsum dolor sit amet, consectetur adipiscing elit. */ + + /* Lorem ipsum dolor sit amet, consectetur adipiscing elit. */ˇ» + "}, + indoc! {" + «/* + * Lorem ipsum dolor sit amet, + * consectetur adipiscing elit. + */ + + /* + * Lorem ipsum dolor sit amet, + * consectetur adipiscing elit. + */ˇ» + "}, + rust_lang.clone(), + &mut cx, + ); + + // selection of multiline block comments + assert_rewrap( + indoc! {" + «/* Lorem ipsum dolor sit amet, + * consectetur adipiscing elit. */ˇ» + "}, + indoc! {" + «/* + * Lorem ipsum dolor sit amet, + * consectetur adipiscing elit. + */ˇ» + "}, + rust_lang.clone(), + &mut cx, + ); + + // partial selection of multiline block comments + assert_rewrap( + indoc! {" + «/* Lorem ipsum dolor sit amet,ˇ» + * consectetur adipiscing elit. */ + /* Lorem ipsum dolor sit amet, + «* consectetur adipiscing elit. */ˇ» + "}, + indoc! {" + «/* + * Lorem ipsum dolor sit amet,ˇ» + * consectetur adipiscing elit. */ + /* Lorem ipsum dolor sit amet, + «* consectetur adipiscing elit. + */ˇ» + "}, + rust_lang.clone(), + &mut cx, + ); + + // selection w/ abutting short block comments + // TODO: should not be combined; should rewrap as 2 comments + assert_rewrap( + indoc! {" + «/* Lorem ipsum dolor sit amet, consectetur adipiscing elit. */ + /* Lorem ipsum dolor sit amet, consectetur adipiscing elit. */ˇ» + "}, + // desired behavior: + // indoc! {" + // «/* + // * Lorem ipsum dolor sit amet, + // * consectetur adipiscing elit. + // */ + // /* + // * Lorem ipsum dolor sit amet, + // * consectetur adipiscing elit. + // */ˇ» + // "}, + // actual behaviour: + indoc! {" + «/* + * Lorem ipsum dolor sit amet, + * consectetur adipiscing elit. Lorem + * ipsum dolor sit amet, consectetur + * adipiscing elit. + */ˇ» + "}, + rust_lang.clone(), + &mut cx, + ); + + // TODO: same as above, but with delimiters on separate line + // assert_rewrap( + // indoc! {" + // «/* Lorem ipsum dolor sit amet, consectetur adipiscing elit. + // */ + // /* + // * Lorem ipsum dolor sit amet, consectetur adipiscing elit. */ˇ» + // "}, + // // desired: + // // indoc! {" + // // «/* + // // * Lorem ipsum dolor sit amet, + // // * consectetur adipiscing elit. + // // */ + // // /* + // // * Lorem ipsum dolor sit amet, + // // * consectetur adipiscing elit. + // // */ˇ» + // // "}, + // // actual: (but with trailing w/s on the empty lines) + // indoc! {" + // «/* + // * Lorem ipsum dolor sit amet, + // * consectetur adipiscing elit. + // * + // */ + // /* + // * + // * Lorem ipsum dolor sit amet, + // * consectetur adipiscing elit. + // */ˇ» + // "}, + // rust_lang.clone(), + // &mut cx, + // ); + + // TODO these are unhandled edge cases; not correct, just documenting known issues + assert_rewrap( + indoc! {" + /* + //ˇ Lorem ipsum dolor sit amet, consectetur adipiscing elit. + */ + /* + //ˇ Lorem ipsum dolor sit amet, consectetur adipiscing elit. */ + /*ˇ Lorem ipsum dolor sit amet */ /* consectetur adipiscing elit. */ + "}, + // desired: + // indoc! {" + // /* + // *ˇ Lorem ipsum dolor sit amet, + // * consectetur adipiscing elit. + // */ + // /* + // *ˇ Lorem ipsum dolor sit amet, + // * consectetur adipiscing elit. + // */ + // /* + // *ˇ Lorem ipsum dolor sit amet + // */ /* consectetur adipiscing elit. */ + // "}, + // actual: + indoc! {" + /* + //ˇ Lorem ipsum dolor sit amet, + // consectetur adipiscing elit. + */ + /* + * //ˇ Lorem ipsum dolor sit amet, + * consectetur adipiscing elit. + */ + /* + *ˇ Lorem ipsum dolor sit amet */ /* + * consectetur adipiscing elit. + */ + "}, + rust_lang, + &mut cx, + ); + + #[track_caller] + fn assert_rewrap( + unwrapped_text: &str, + wrapped_text: &str, + language: Arc, + cx: &mut EditorTestContext, + ) { + cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); + cx.set_state(unwrapped_text); + cx.update_editor(|e, _, cx| e.rewrap(RewrapOptions::default(), cx)); + cx.assert_editor_state(wrapped_text); + } +} + +#[gpui::test] +async fn test_rewrap_line_comment_in_go(cx: &mut TestAppContext) { + init_test(cx, |settings| { + settings.languages.0.extend([( + "Go".into(), + LanguageSettingsContent { + allow_rewrap: Some(language_settings::RewrapBehavior::InComments), + preferred_line_length: Some(40), + ..Default::default() + }, + )]) + }); + + let mut cx = EditorTestContext::new(cx).await; + + let go_lang = languages::language("go", tree_sitter_go::LANGUAGE.into()); + + cx.update_buffer(|buffer, cx| buffer.set_language(Some(go_lang), cx)); + cx.set_state(indoc! {" + // Lorem ipsum dolor sit amet, consectetur adipiscing elit.ˇ + "}); + cx.update_editor(|e, _, cx| e.rewrap(RewrapOptions::default(), cx)); + cx.assert_editor_state(indoc! {" + // Lorem ipsum dolor sit amet, + // consectetur adipiscing elit.ˇ + "}); +} + +#[gpui::test] +async fn test_rewrap_line_comment_in_c(cx: &mut TestAppContext) { + init_test(cx, |settings| { + settings.languages.0.extend([( + "C".into(), + LanguageSettingsContent { + allow_rewrap: Some(language_settings::RewrapBehavior::InComments), + preferred_line_length: Some(40), + ..Default::default() + }, + )]) + }); + + let mut cx = EditorTestContext::new(cx).await; + + let c_lang = languages::language("c", tree_sitter_c::LANGUAGE.into()); + + cx.update_buffer(|buffer, cx| buffer.set_language(Some(c_lang), cx)); + cx.set_state(indoc! {" + // Lorem ipsum dolor sit amet, consectetur adipiscing elit.ˇ + "}); + cx.update_editor(|e, _, cx| e.rewrap(RewrapOptions::default(), cx)); + cx.assert_editor_state(indoc! {" + // Lorem ipsum dolor sit amet, + // consectetur adipiscing elit.ˇ + "}); +} + +#[gpui::test] +async fn test_hard_wrap(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + + cx.update_buffer(|buffer, cx| buffer.set_language(Some(git_commit_lang()), cx)); + cx.update_editor(|editor, _, cx| { + editor.set_hard_wrap(Some(14), cx); + }); + + cx.set_state(indoc!( + " + one two three ˇ + " + )); + cx.simulate_input("four"); + cx.run_until_parked(); + + cx.assert_editor_state(indoc!( + " + one two three + fourˇ + " + )); + + cx.update_editor(|editor, window, cx| { + editor.newline(&Default::default(), window, cx); + }); + cx.run_until_parked(); + cx.assert_editor_state(indoc!( + " + one two three + four + ˇ + " + )); + + cx.simulate_input("five"); + cx.run_until_parked(); + cx.assert_editor_state(indoc!( + " + one two three + four + fiveˇ + " + )); + + cx.update_editor(|editor, window, cx| { + editor.newline(&Default::default(), window, cx); + }); + cx.run_until_parked(); + cx.simulate_input("# "); + cx.run_until_parked(); + cx.assert_editor_state(indoc!( + " + one two three + four + five + # ˇ + " + )); + + cx.update_editor(|editor, window, cx| { + editor.newline(&Default::default(), window, cx); + }); + cx.run_until_parked(); + cx.assert_editor_state(indoc!( + " + one two three + four + five + #\x20 + #ˇ + " + )); + + cx.simulate_input(" 6"); + cx.run_until_parked(); + cx.assert_editor_state(indoc!( + " + one two three + four + five + # + # 6ˇ + " + )); +} + +#[gpui::test] +async fn test_cut_line_ends(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + cx.set_state(indoc! {"The quick brownˇ"}); + cx.update_editor(|e, window, cx| e.cut_to_end_of_line(&CutToEndOfLine::default(), window, cx)); + cx.assert_editor_state(indoc! {"The quick brownˇ"}); + + cx.set_state(indoc! {"The emacs foxˇ"}); + cx.update_editor(|e, window, cx| e.kill_ring_cut(&KillRingCut, window, cx)); + cx.assert_editor_state(indoc! {"The emacs foxˇ"}); + + cx.set_state(indoc! {" + The quick« brownˇ» + fox jumps overˇ + the lazy dog"}); + cx.update_editor(|e, window, cx| e.cut(&Cut, window, cx)); + cx.assert_editor_state(indoc! {" + The quickˇ + ˇthe lazy dog"}); + + cx.set_state(indoc! {" + The quick« brownˇ» + fox jumps overˇ + the lazy dog"}); + cx.update_editor(|e, window, cx| e.cut_to_end_of_line(&CutToEndOfLine::default(), window, cx)); + cx.assert_editor_state(indoc! {" + The quickˇ + fox jumps overˇthe lazy dog"}); + + cx.set_state(indoc! {" + The quick« brownˇ» + fox jumps overˇ + the lazy dog"}); + cx.update_editor(|e, window, cx| { + e.cut_to_end_of_line( + &CutToEndOfLine { + stop_at_newlines: true, + }, + window, + cx, + ) + }); + cx.assert_editor_state(indoc! {" + The quickˇ + fox jumps overˇ + the lazy dog"}); + + cx.set_state(indoc! {" + The quick« brownˇ» + fox jumps overˇ + the lazy dog"}); + cx.update_editor(|e, window, cx| e.kill_ring_cut(&KillRingCut, window, cx)); + cx.assert_editor_state(indoc! {" + The quickˇ + fox jumps overˇthe lazy dog"}); + + for selection in ["The quick «brownˇ» fox", "The quick «ˇbrown» fox"] { + cx.set_state(selection); + cx.update_editor(|e, window, cx| { + e.cut_to_end_of_line(&CutToEndOfLine::default(), window, cx) + }); + cx.assert_editor_state("The quick ˇ"); + assert_eq!( + cx.read_from_clipboard() + .and_then(|item| item.text().as_deref().map(str::to_string)), + Some("brown fox".to_string()) + ); + } +} + +#[gpui::test] +async fn test_kill_ring_cut_accumulates_multi_line_kills(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + cx.set_state("ˇone\ntwo"); + + cx.update_editor(|e, window, cx| { + e.kill_ring_cut(&KillRingCut, window, cx); + e.kill_ring_cut(&KillRingCut, window, cx); + }); + + cx.update_editor(|e, window, cx| e.kill_ring_yank(&KillRingYank, window, cx)); + cx.assert_editor_state("one\nˇtwo"); +} + +#[gpui::test] +async fn test_kill_ring_cut_matches_emacs_kill_line_sequence_at_end_of_buffer( + cx: &mut TestAppContext, +) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + cx.set_state("ˇa\nb\nc"); + + cx.update_editor(|editor, window, cx| editor.kill_ring_cut(&KillRingCut, window, cx)); + cx.update_editor(|editor, window, cx| editor.kill_ring_cut(&KillRingCut, window, cx)); + cx.update_editor(|editor, window, cx| editor.kill_ring_cut(&KillRingCut, window, cx)); + + cx.update_editor(|editor, _, cx| { + assert_eq!(editor.text(cx), "\nc"); + }); + + cx.update_editor(|editor, window, cx| editor.kill_ring_yank(&KillRingYank, window, cx)); + cx.update_editor(|editor, _, cx| { + assert_eq!(editor.text(cx), "a\nb\nc"); + }); +} + +#[gpui::test] +async fn test_kill_ring_cut_accumulates_final_line_without_trailing_newline( + cx: &mut TestAppContext, +) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + cx.set_state("ˇa\nb\nc"); + + cx.update_editor(|editor, window, cx| editor.kill_ring_cut(&KillRingCut, window, cx)); + cx.update_editor(|editor, window, cx| editor.kill_ring_cut(&KillRingCut, window, cx)); + cx.update_editor(|editor, window, cx| editor.kill_ring_cut(&KillRingCut, window, cx)); + cx.update_editor(|editor, window, cx| editor.kill_ring_cut(&KillRingCut, window, cx)); + cx.update_editor(|editor, window, cx| editor.kill_ring_cut(&KillRingCut, window, cx)); + + cx.update_editor(|editor, _, cx| { + assert_eq!(editor.text(cx), ""); + }); + + cx.update_editor(|editor, window, cx| editor.kill_ring_yank(&KillRingYank, window, cx)); + cx.update_editor(|editor, _, cx| { + assert_eq!(editor.text(cx), "a\nb\nc"); + }); +} + +#[gpui::test] +async fn test_kill_ring_yank_breaks_kill_ring_cut_accumulation(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + cx.set_state("ˇa\nb\nc"); + + cx.update_editor(|editor, window, cx| editor.kill_ring_cut(&KillRingCut, window, cx)); + cx.update_editor(|editor, window, cx| editor.kill_ring_cut(&KillRingCut, window, cx)); + cx.update_editor(|editor, window, cx| editor.kill_ring_yank(&KillRingYank, window, cx)); + cx.update_editor(|editor, window, cx| editor.move_to_beginning(&MoveToBeginning, window, cx)); + cx.update_editor(|editor, window, cx| editor.kill_ring_cut(&KillRingCut, window, cx)); + cx.update_editor(|editor, window, cx| editor.kill_ring_cut(&KillRingCut, window, cx)); + cx.update_editor(|editor, window, cx| editor.kill_ring_yank(&KillRingYank, window, cx)); + + cx.update_editor(|editor, _, cx| { + assert_eq!(editor.text(cx), "a\nb\nc"); + }); +} + +#[gpui::test] +async fn test_kill_ring_yank_pastes_accumulated_kill_at_each_cursor(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + cx.set_state("ˇone\ntwo"); + + cx.update_editor(|editor, window, cx| editor.kill_ring_cut(&KillRingCut, window, cx)); + cx.update_editor(|editor, window, cx| editor.kill_ring_cut(&KillRingCut, window, cx)); + + cx.set_state("aˇ bˇ"); + cx.update_editor(|editor, window, cx| editor.kill_ring_yank(&KillRingYank, window, cx)); + cx.assert_editor_state("aone\nˇ bone\nˇ"); +} + +#[gpui::test] +async fn test_editing_untitled_buffer_redetects_language(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + update_test_editor_settings(cx, &|settings| { + settings.language_detection = Some(false); + }); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [], cx).await; + let go_language = language("go", tree_sitter_go::LANGUAGE.into()); + project.read_with(cx, |project, _| { + project.languages().add(rust_lang()); + project.languages().add(go_language.clone()); + }); + let buffer = project + .update(cx, |project, cx| project.create_buffer(None, true, cx)) + .await + .unwrap(); + buffer.update(cx, |buffer, _| { + buffer.set_content_language_detection_enabled(true); + }); + let window = cx.add_window(|window, cx| { + let editor = Editor::for_buffer(buffer.clone(), None, window, cx); + window.focus(&editor.focus_handle(cx), cx); + editor + }); + let editor = window.root(cx).unwrap(); + let cx = &mut VisualTestContext::from_window(*window, cx); + + assert_eq!( + buffer.read_with(cx, |buffer, _| buffer.language().unwrap().name()), + PLAIN_TEXT.name() + ); + + editor.update_in(cx, |editor, window, cx| { + editor.insert("fn main() { println!(\"hello\"); }", window, cx); + }); + cx.run_until_parked(); + cx.executor() + .advance_clock(LANGUAGE_DETECTION_DEBOUNCE_TIMEOUT); + cx.run_until_parked(); + + assert_eq!( + buffer.read_with(cx, |buffer, _| buffer.language().unwrap().name()), + PLAIN_TEXT.name() + ); + + update_test_editor_settings(cx, &|settings| { + settings.language_detection = Some(true); + }); + + editor.update_in(cx, |editor, window, cx| { + editor.select_all(&SelectAll, window, cx); + editor.insert("fn main() {}", window, cx); + }); + cx.run_until_parked(); + + assert_eq!( + buffer.read_with(cx, |buffer, _| buffer.language().unwrap().name()), + PLAIN_TEXT.name() + ); + + editor.update_in(cx, |editor, window, cx| { + editor.select_all(&SelectAll, window, cx); + editor.insert("fn main() { println!(\"hello\"); }", window, cx); + }); + cx.run_until_parked(); + + cx.executor() + .advance_clock(LANGUAGE_DETECTION_DEBOUNCE_TIMEOUT / 2); + editor.update_in(cx, |editor, window, cx| { + editor.insert(" ", window, cx); + }); + cx.run_until_parked(); + + cx.executor() + .advance_clock(LANGUAGE_DETECTION_DEBOUNCE_TIMEOUT / 2); + cx.run_until_parked(); + + assert_eq!( + buffer.read_with(cx, |buffer, _| buffer.language().unwrap().name()), + PLAIN_TEXT.name() + ); + + cx.executor() + .advance_clock(LANGUAGE_DETECTION_DEBOUNCE_TIMEOUT / 2); + cx.run_until_parked(); + + assert_eq!( + buffer.read_with(cx, |buffer, _| buffer.language().unwrap().name()), + rust_lang().name() + ); + + editor.update_in(cx, |editor, window, cx| { + editor.select_all(&SelectAll, window, cx); + editor.backspace(&Backspace, window, cx); + }); + cx.run_until_parked(); + + editor.update_in(cx, |editor, window, cx| { + cx.write_to_clipboard(ClipboardItem::new_string( + "package main\n\nimport \"fmt\"\n\nfunc main() { fmt.Println(\"hello\") }".to_string(), + )); + editor.paste(&Paste, window, cx); + }); + cx.run_until_parked(); + + assert_eq!( + buffer.read_with(cx, |buffer, _| buffer.language().unwrap().name()), + rust_lang().name() + ); + + cx.executor() + .advance_clock(LANGUAGE_DETECTION_DEBOUNCE_TIMEOUT); + cx.run_until_parked(); + + assert_eq!( + buffer.read_with(cx, |buffer, _| buffer.language().unwrap().name()), + go_language.name() + ); + + project.update(cx, |project, cx| { + project.set_language_for_buffer(&buffer, PLAIN_TEXT.clone(), cx); + }); + + editor.update_in(cx, |editor, window, cx| { + editor.select_all(&SelectAll, window, cx); + editor.insert("fn main() { println!(\"hello\"); }", window, cx); + }); + + cx.run_until_parked(); + cx.executor() + .advance_clock(LANGUAGE_DETECTION_DEBOUNCE_TIMEOUT); + cx.run_until_parked(); + + assert_eq!( + buffer.read_with(cx, |buffer, _| buffer.language().unwrap().name()), + PLAIN_TEXT.name() + ); +} + +#[gpui::test] +async fn test_clipboard(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + cx.set_state("«one✅ ˇ»two «three ˇ»four «five ˇ»six "); + cx.update_editor(|e, window, cx| e.cut(&Cut, window, cx)); + cx.assert_editor_state("ˇtwo ˇfour ˇsix "); + + // Paste with three cursors. Each cursor pastes one slice of the clipboard text. + cx.set_state("two ˇfour ˇsix ˇ"); + cx.update_editor(|e, window, cx| e.paste(&Paste, window, cx)); + cx.assert_editor_state("two one✅ ˇfour three ˇsix five ˇ"); + + // Paste again but with only two cursors. Since the number of cursors doesn't + // match the number of slices in the clipboard, the entire clipboard text + // is pasted at each cursor. + cx.set_state("ˇtwo one✅ four three six five ˇ"); + cx.update_editor(|e, window, cx| { + e.handle_input("( ", window, cx); + e.paste(&Paste, window, cx); + e.handle_input(") ", window, cx); + }); + cx.assert_editor_state( + &([ + "( one✅ ", + "three ", + "five ) ˇtwo one✅ four three six five ( one✅ ", + "three ", + "five ) ˇ", + ] + .join("\n")), + ); + + // Cut with three selections, one of which is full-line. + cx.set_state(indoc! {" + 1«2ˇ»3 + 4ˇ567 + «8ˇ»9"}); + cx.update_editor(|e, window, cx| e.cut(&Cut, window, cx)); + cx.assert_editor_state(indoc! {" + 1ˇ3 + ˇ9"}); + + // Paste with three selections, noticing how the copied selection that was full-line + // gets inserted before the second cursor. + cx.set_state(indoc! {" + 1ˇ3 + 9ˇ + «oˇ»ne"}); + cx.update_editor(|e, window, cx| e.paste(&Paste, window, cx)); + cx.assert_editor_state(indoc! {" + 12ˇ3 + 4567 + 9ˇ + 8ˇne"}); + + // Copy with a single cursor only, which writes the whole line into the clipboard. + cx.set_state(indoc! {" + The quick brown + fox juˇmps over + the lazy dog"}); + cx.update_editor(|e, window, cx| e.copy(&Copy, window, cx)); + assert_eq!( + cx.read_from_clipboard() + .and_then(|item| item.text().as_deref().map(str::to_string)), + Some("fox jumps over\n".to_string()) + ); + + // Paste with three selections, noticing how the copied full-line selection is inserted + // before the empty selections but replaces the selection that is non-empty. + cx.set_state(indoc! {" + Tˇhe quick brown + «foˇ»x jumps over + tˇhe lazy dog"}); + cx.update_editor(|e, window, cx| e.paste(&Paste, window, cx)); + cx.assert_editor_state(indoc! {" + fox jumps over + Tˇhe quick brown + fox jumps over + ˇx jumps over + fox jumps over + tˇhe lazy dog"}); +} + +#[gpui::test] +async fn test_copy_and_paste_non_empty_selection_followed_by_empty_selection( + cx: &mut TestAppContext, +) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + cx.set_state(indoc! {" + «fooˇ» + barˇ + "}); + + cx.update_editor(|editor, window, cx| editor.copy(&Copy, window, cx)); + assert_eq!( + cx.read_from_clipboard().and_then(|item| item.text()), + Some("foo\nbar\n".to_string()) + ); + + cx.update_editor(|editor, window, cx| editor.paste(&Paste, window, cx)); + cx.assert_editor_state(indoc! {" + fooˇ + bar + barˇ + "}); +} + +#[gpui::test] +async fn test_copy_trim(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + cx.set_state( + r#" «for selection in selections.iter() { + let mut start = selection.start; + let mut end = selection.end; + let is_entire_line = selection.is_empty(); + if is_entire_line { + start = Point::new(start.row, 0);ˇ» + end = cmp::min(max_point, Point::new(end.row + 1, 0)); + } + "#, + ); + cx.update_editor(|e, window, cx| e.copy(&Copy, window, cx)); + assert_eq!( + cx.read_from_clipboard() + .and_then(|item| item.text().as_deref().map(str::to_string)), + Some( + "for selection in selections.iter() { + let mut start = selection.start; + let mut end = selection.end; + let is_entire_line = selection.is_empty(); + if is_entire_line { + start = Point::new(start.row, 0);" + .to_string() + ), + "Regular copying preserves all indentation selected", + ); + cx.update_editor(|e, window, cx| e.copy_and_trim(&CopyAndTrim, window, cx)); + assert_eq!( + cx.read_from_clipboard() + .and_then(|item| item.text().as_deref().map(str::to_string)), + Some( + "for selection in selections.iter() { +let mut start = selection.start; +let mut end = selection.end; +let is_entire_line = selection.is_empty(); +if is_entire_line { + start = Point::new(start.row, 0);" + .to_string() + ), + "Copying with stripping should strip all leading whitespaces" + ); + + cx.set_state( + r#" « for selection in selections.iter() { + let mut start = selection.start; + let mut end = selection.end; + let is_entire_line = selection.is_empty(); + if is_entire_line { + start = Point::new(start.row, 0);ˇ» + end = cmp::min(max_point, Point::new(end.row + 1, 0)); + } + "#, + ); + cx.update_editor(|e, window, cx| e.copy(&Copy, window, cx)); + assert_eq!( + cx.read_from_clipboard() + .and_then(|item| item.text().as_deref().map(str::to_string)), + Some( + " for selection in selections.iter() { + let mut start = selection.start; + let mut end = selection.end; + let is_entire_line = selection.is_empty(); + if is_entire_line { + start = Point::new(start.row, 0);" + .to_string() + ), + "Regular copying preserves all indentation selected", + ); + cx.update_editor(|e, window, cx| e.copy_and_trim(&CopyAndTrim, window, cx)); + assert_eq!( + cx.read_from_clipboard() + .and_then(|item| item.text().as_deref().map(str::to_string)), + Some( + "for selection in selections.iter() { +let mut start = selection.start; +let mut end = selection.end; +let is_entire_line = selection.is_empty(); +if is_entire_line { + start = Point::new(start.row, 0);" + .to_string() + ), + "Copying with stripping should strip all leading whitespaces, even if some of it was selected" + ); + + cx.set_state( + r#" «ˇ for selection in selections.iter() { + let mut start = selection.start; + let mut end = selection.end; + let is_entire_line = selection.is_empty(); + if is_entire_line { + start = Point::new(start.row, 0);» + end = cmp::min(max_point, Point::new(end.row + 1, 0)); + } + "#, + ); + cx.update_editor(|e, window, cx| e.copy(&Copy, window, cx)); + assert_eq!( + cx.read_from_clipboard() + .and_then(|item| item.text().as_deref().map(str::to_string)), + Some( + " for selection in selections.iter() { + let mut start = selection.start; + let mut end = selection.end; + let is_entire_line = selection.is_empty(); + if is_entire_line { + start = Point::new(start.row, 0);" + .to_string() + ), + "Regular copying for reverse selection works the same", + ); + cx.update_editor(|e, window, cx| e.copy_and_trim(&CopyAndTrim, window, cx)); + assert_eq!( + cx.read_from_clipboard() + .and_then(|item| item.text().as_deref().map(str::to_string)), + Some( + "for selection in selections.iter() { +let mut start = selection.start; +let mut end = selection.end; +let is_entire_line = selection.is_empty(); +if is_entire_line { + start = Point::new(start.row, 0);" + .to_string() + ), + "Copying with stripping for reverse selection works the same" + ); + + cx.set_state( + r#" for selection «in selections.iter() { + let mut start = selection.start; + let mut end = selection.end; + let is_entire_line = selection.is_empty(); + if is_entire_line { + start = Point::new(start.row, 0);ˇ» + end = cmp::min(max_point, Point::new(end.row + 1, 0)); + } + "#, + ); + cx.update_editor(|e, window, cx| e.copy(&Copy, window, cx)); + assert_eq!( + cx.read_from_clipboard() + .and_then(|item| item.text().as_deref().map(str::to_string)), + Some( + "in selections.iter() { + let mut start = selection.start; + let mut end = selection.end; + let is_entire_line = selection.is_empty(); + if is_entire_line { + start = Point::new(start.row, 0);" + .to_string() + ), + "When selecting past the indent, the copying works as usual", + ); + cx.update_editor(|e, window, cx| e.copy_and_trim(&CopyAndTrim, window, cx)); + assert_eq!( + cx.read_from_clipboard() + .and_then(|item| item.text().as_deref().map(str::to_string)), + Some( + "in selections.iter() { + let mut start = selection.start; + let mut end = selection.end; + let is_entire_line = selection.is_empty(); + if is_entire_line { + start = Point::new(start.row, 0);" + .to_string() + ), + "When selecting past the indent, nothing is trimmed" + ); + + cx.set_state( + r#" «for selection in selections.iter() { + let mut start = selection.start; + + let mut end = selection.end; + let is_entire_line = selection.is_empty(); + if is_entire_line { + start = Point::new(start.row, 0); +ˇ» end = cmp::min(max_point, Point::new(end.row + 1, 0)); + } + "#, + ); + cx.update_editor(|e, window, cx| e.copy_and_trim(&CopyAndTrim, window, cx)); + assert_eq!( + cx.read_from_clipboard() + .and_then(|item| item.text().as_deref().map(str::to_string)), + Some( + "for selection in selections.iter() { +let mut start = selection.start; + +let mut end = selection.end; +let is_entire_line = selection.is_empty(); +if is_entire_line { + start = Point::new(start.row, 0); +" + .to_string() + ), + "Copying with stripping should ignore empty lines" + ); +} + +#[gpui::test] +async fn test_copy_trim_line_mode(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + cx.set_state(indoc! {" + « fn main() { + 1 + }ˇ» + "}); + cx.update_editor(|editor, _window, _cx| editor.selections.set_line_mode(true)); + cx.update_editor(|editor, window, cx| editor.copy_and_trim(&CopyAndTrim, window, cx)); + + assert_eq!( + cx.read_from_clipboard().and_then(|item| item.text()), + Some("fn main() {\n 1\n}\n".to_string()) + ); + + let clipboard_selections: Vec = cx + .read_from_clipboard() + .and_then(|item| item.entries().first().cloned()) + .and_then(|entry| match entry { + gpui::ClipboardEntry::String(text) => text.metadata_json(), + _ => None, + }) + .expect("should have clipboard selections"); + + assert_eq!(clipboard_selections.len(), 1); + assert!(clipboard_selections[0].is_entire_line); + + cx.set_state(indoc! {" + «fn main() { + 1 + }ˇ» + "}); + cx.update_editor(|editor, _window, _cx| editor.selections.set_line_mode(true)); + cx.update_editor(|editor, window, cx| editor.copy_and_trim(&CopyAndTrim, window, cx)); + + assert_eq!( + cx.read_from_clipboard().and_then(|item| item.text()), + Some("fn main() {\n 1\n}\n".to_string()) + ); + + let clipboard_selections: Vec = cx + .read_from_clipboard() + .and_then(|item| item.entries().first().cloned()) + .and_then(|entry| match entry { + gpui::ClipboardEntry::String(text) => text.metadata_json(), + _ => None, + }) + .expect("should have clipboard selections"); + + assert_eq!(clipboard_selections.len(), 1); + assert!(clipboard_selections[0].is_entire_line); +} + +#[gpui::test] +async fn test_clipboard_line_numbers_from_multibuffer(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let fs = FakeFs::new(cx.executor()); + fs.insert_file( + path!("/file.txt"), + "first line\nsecond line\nthird line\nfourth line\nfifth line\n".into(), + ) + .await; + + let project = Project::test(fs, [path!("/file.txt").as_ref()], cx).await; + + let buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/file.txt"), cx) + }) + .await + .unwrap(); + + let multibuffer = cx.new(|cx| { + let mut multibuffer = MultiBuffer::new(ReadWrite); + multibuffer.set_excerpts_for_path( + PathKey::sorted(0), + buffer.clone(), + [Point::new(2, 0)..Point::new(5, 0)], + 0, + cx, + ); + multibuffer + }); + + let (editor, cx) = cx.add_window_view(|window, cx| { + build_editor_with_project(project.clone(), multibuffer, window, cx) + }); + + editor.update_in(cx, |editor, window, cx| { + assert_eq!(editor.text(cx), "third line\nfourth line\nfifth line\n"); + + editor.select_all(&SelectAll, window, cx); + editor.copy(&Copy, window, cx); + }); + + let clipboard_selections: Option> = cx + .read_from_clipboard() + .and_then(|item| item.entries().first().cloned()) + .and_then(|entry| match entry { + gpui::ClipboardEntry::String(text) => text.metadata_json(), + _ => None, + }); + + let selections = clipboard_selections.expect("should have clipboard selections"); + assert_eq!(selections.len(), 1); + let selection = &selections[0]; + assert_eq!( + selection.line_range, + Some(2..=5), + "line range should be from original file (rows 2-5), not multibuffer rows (0-2)" + ); +} + +#[gpui::test] +async fn test_copy_file_location_from_multibuffer(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/root"), + json!({ + "file.txt": "first line\nsecond line\nthird line\nfourth line\nfifth line\n", + }), + ) + .await; + + let project = Project::test(fs, [path!("/root").as_ref()], cx).await; + + let buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/root/file.txt"), cx) + }) + .await + .unwrap(); + + let multibuffer = cx.new(|cx| { + let mut multibuffer = MultiBuffer::new(ReadWrite); + multibuffer.set_excerpts_for_path( + PathKey::sorted(0), + buffer.clone(), + [Point::new(2, 0)..Point::new(5, 0)], + 0, + cx, + ); + multibuffer + }); + + let (editor, cx) = cx.add_window_view(|window, cx| { + build_editor_with_project(project.clone(), multibuffer, window, cx) + }); + + editor.update_in(cx, |editor, window, cx| { + editor.change_selections(Default::default(), window, cx, |selections| { + selections.select_ranges([Point::new(0, 0)..Point::new(0, 0)]); + }); + editor.copy_file_location(&CopyFileLocation, window, cx); + }); + assert_eq!( + cx.read_from_clipboard().and_then(|item| item.text()), + Some("file.txt:3".to_string()), + "cursor on the first excerpt row should report its line in the original file" + ); + + editor.update_in(cx, |editor, window, cx| { + editor.change_selections(Default::default(), window, cx, |selections| { + selections.select_ranges([Point::new(1, 0)..Point::new(2, 0)]); + }); + editor.copy_file_location(&CopyFileLocation, window, cx); + }); + assert_eq!( + cx.read_from_clipboard().and_then(|item| item.text()), + Some("file.txt:4".to_string()), + "a selection ending at the start of a row should not include that row" + ); + + editor.update_in(cx, |editor, window, cx| { + editor.change_selections(Default::default(), window, cx, |selections| { + selections.select_ranges([Point::new(0, 0)..Point::new(2, 3)]); + }); + editor.copy_file_location(&CopyFileLocation, window, cx); + }); + assert_eq!( + cx.read_from_clipboard().and_then(|item| item.text()), + Some("file.txt:3-5".to_string()), + "a multi-row selection should report the original file's line range" + ); +} + +#[gpui::test] +async fn test_copy_file_name_for_external_file(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/root"), json!({ "a.txt": "" })).await; + fs.insert_tree(path!("/elsewhere"), json!({ "external.csv": "a,b\n" })) + .await; + + let project = Project::test(fs, [path!("/root").as_ref()], cx).await; + + let buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/elsewhere/external.csv"), cx) + }) + .await + .unwrap(); + + let (editor, cx) = cx.add_window_view(|window, cx| { + Editor::for_buffer(buffer, Some(project.clone()), window, cx) + }); + + editor.update_in(cx, |editor, window, cx| { + editor.copy_file_name(&CopyFileName, window, cx); + }); + assert_eq!( + cx.read_from_clipboard().and_then(|item| item.text()), + Some("external.csv".to_string()), + "copy_file_name should work for a file outside the project" + ); + + editor.update_in(cx, |editor, window, cx| { + editor.copy_file_name_without_extension(&CopyFileNameWithoutExtension, window, cx); + }); + assert_eq!( + cx.read_from_clipboard().and_then(|item| item.text()), + Some("external".to_string()), + "copy_file_name_without_extension should work for a file outside the project" + ); +} + +#[gpui::test] +async fn test_copy_file_location_across_buffers(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/root"), + json!({ + "one.txt": "one\ntwo\nthree\n", + "two.txt": "four\nfive\nsix\n", + }), + ) + .await; + + let project = Project::test(fs, [path!("/root").as_ref()], cx).await; + + let buffer_1 = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/root/one.txt"), cx) + }) + .await + .unwrap(); + let buffer_2 = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/root/two.txt"), cx) + }) + .await + .unwrap(); + + let multibuffer = cx.new(|cx| { + let mut multibuffer = MultiBuffer::new(ReadWrite); + multibuffer.set_excerpts_for_path( + PathKey::sorted(0), + buffer_1, + [Point::new(1, 0)..Point::new(3, 0)], + 0, + cx, + ); + multibuffer.set_excerpts_for_path( + PathKey::sorted(1), + buffer_2, + [Point::new(1, 0)..Point::new(3, 0)], + 0, + cx, + ); + multibuffer + }); + + let (editor, cx) = cx.add_window_view(|window, cx| { + build_editor_with_project(project.clone(), multibuffer, window, cx) + }); + + // The multi-buffer reads "two\nthree\n\nfive\nsix\n", so row 3 is the first + // row of the second buffer's excerpt. Select from the first excerpt into it. + editor.update_in(cx, |editor, window, cx| { + editor.change_selections(Default::default(), window, cx, |selections| { + selections.select_ranges([Point::new(0, 0)..Point::new(3, 2)]); + }); + editor.copy_file_location(&CopyFileLocation, window, cx); + }); + assert_eq!( + cx.read_from_clipboard().and_then(|item| item.text()), + Some("two.txt:2".to_string()), + "a selection spanning buffers should report the location of the cursor" + ); + + editor.update_in(cx, |editor, window, cx| { + editor.change_selections(Default::default(), window, cx, |selections| { + selections.select_ranges([Point::new(3, 2)..Point::new(0, 0)]); + }); + editor.copy_file_location(&CopyFileLocation, window, cx); + }); + assert_eq!( + cx.read_from_clipboard().and_then(|item| item.text()), + Some("one.txt:2".to_string()), + "a reversed selection spanning buffers should report the cursor's location in the first buffer" + ); +} + +#[gpui::test] +async fn test_copy_file_location_with_deleted_hunk(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + + cx.set_state("ˇaaa\nbbb\nccc"); + cx.set_head_text("aaa\nXXX\nbbb\nccc"); + cx.run_until_parked(); + cx.update_editor(|editor, window, cx| { + editor.expand_all_diff_hunks(&Default::default(), window, cx); + }); + cx.run_until_parked(); + + // Multi-buffer rows: 0 "aaa", 1 "XXX" (deleted hunk), 2 "bbb", 3 "ccc" + cx.update_editor(|editor, window, cx| { + editor.change_selections(Default::default(), window, cx, |selections| { + selections.select_ranges([Point::new(0, 0)..Point::new(2, 2)]); + }); + editor.copy_file_location(&CopyFileLocation, window, cx); + }); + assert_eq!( + cx.read_from_clipboard().and_then(|item| item.text()), + Some("file:2".to_string()), + "a selection crossing a deleted hunk should report the location of the cursor" + ); + + cx.update_editor(|editor, window, cx| { + editor.change_selections(Default::default(), window, cx, |selections| { + selections.select_ranges([Point::new(0, 0)..Point::new(0, 0)]); + }); + editor.copy_file_location(&CopyFileLocation, window, cx); + }); + assert_eq!( + cx.read_from_clipboard().and_then(|item| item.text()), + Some("file:1".to_string()) + ); + + cx.update_editor(|editor, window, cx| { + editor.change_selections(Default::default(), window, cx, |selections| { + selections.select_ranges([Point::new(1, 1)..Point::new(1, 1)]); + }); + editor.copy_file_location(&CopyFileLocation, window, cx); + }); + assert_eq!( + cx.read_from_clipboard().and_then(|item| item.text()), + Some("file:1".to_string()), + "a cursor inside a deleted hunk has no file location, so the clipboard is unchanged" + ); +} + +#[gpui::test] +async fn test_copy_file_location_in_singleton_buffer(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/root"), + json!({ + "file.txt": "first line\nsecond line\nthird line\n", + }), + ) + .await; + + let project = Project::test(fs, [path!("/root").as_ref()], cx).await; + + let buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/root/file.txt"), cx) + }) + .await + .unwrap(); + let multibuffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + + let (editor, cx) = cx.add_window_view(|window, cx| { + build_editor_with_project(project.clone(), multibuffer, window, cx) + }); + + editor.update_in(cx, |editor, window, cx| { + editor.change_selections(Default::default(), window, cx, |selections| { + selections.select_ranges([Point::new(1, 0)..Point::new(1, 0)]); + }); + editor.copy_file_location(&CopyFileLocation, window, cx); + }); + assert_eq!( + cx.read_from_clipboard().and_then(|item| item.text()), + Some("file.txt:2".to_string()) + ); + + editor.update_in(cx, |editor, window, cx| { + editor.change_selections(Default::default(), window, cx, |selections| { + selections.select_ranges([Point::new(0, 0)..Point::new(2, 4)]); + }); + editor.copy_file_location(&CopyFileLocation, window, cx); + }); + assert_eq!( + cx.read_from_clipboard().and_then(|item| item.text()), + Some("file.txt:1-3".to_string()) + ); +} + +#[gpui::test] +async fn test_paste_multiline(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + cx.update_buffer(|buffer, cx| buffer.set_language(Some(rust_lang()), cx)); + + // Cut an indented block, without the leading whitespace. + cx.set_state(indoc! {" + const a: B = ( + c(), + «d( + e, + f + )ˇ» + ); + "}); + cx.update_editor(|e, window, cx| e.cut(&Cut, window, cx)); + cx.assert_editor_state(indoc! {" + const a: B = ( + c(), + ˇ + ); + "}); + + // Paste it at the same position. + cx.update_editor(|e, window, cx| e.paste(&Paste, window, cx)); + cx.assert_editor_state(indoc! {" + const a: B = ( + c(), + d( + e, + f + )ˇ + ); + "}); + + // Paste it at a line with a lower indent level. + cx.set_state(indoc! {" + ˇ + const a: B = ( + c(), + ); + "}); + cx.update_editor(|e, window, cx| e.paste(&Paste, window, cx)); + cx.assert_editor_state(indoc! {" + d( + e, + f + )ˇ + const a: B = ( + c(), + ); + "}); + + // Cut an indented block, with the leading whitespace. + cx.set_state(indoc! {" + const a: B = ( + c(), + « d( + e, + f + ) + ˇ»); + "}); + cx.update_editor(|e, window, cx| e.cut(&Cut, window, cx)); + cx.assert_editor_state(indoc! {" + const a: B = ( + c(), + ˇ); + "}); + + // Paste it at the same position. + cx.update_editor(|e, window, cx| e.paste(&Paste, window, cx)); + cx.assert_editor_state(indoc! {" + const a: B = ( + c(), + d( + e, + f + ) + ˇ); + "}); + + // Paste it at a line with a higher indent level. + cx.set_state(indoc! {" + const a: B = ( + c(), + d( + e, + fˇ + ) + ); + "}); + cx.update_editor(|e, window, cx| e.paste(&Paste, window, cx)); + cx.assert_editor_state(indoc! {" + const a: B = ( + c(), + d( + e, + f d( + e, + f + ) + ˇ + ) + ); + "}); + + // Copy an indented block, starting mid-line + cx.set_state(indoc! {" + const a: B = ( + c(), + somethin«g( + e, + f + )ˇ» + ); + "}); + cx.update_editor(|e, window, cx| e.copy(&Copy, window, cx)); + + // Paste it on a line with a lower indent level + cx.update_editor(|e, window, cx| e.move_to_end(&Default::default(), window, cx)); + cx.update_editor(|e, window, cx| e.paste(&Paste, window, cx)); + cx.assert_editor_state(indoc! {" + const a: B = ( + c(), + something( + e, + f + ) + ); + g( + e, + f + )ˇ"}); +} + +#[gpui::test] +async fn test_paste_undo_does_not_include_preceding_edits(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + cx.update_editor(|e, _, cx| { + e.buffer().update(cx, |buffer, cx| { + buffer.set_group_interval(Duration::from_secs(10), cx) + }) + }); + // Type some text + cx.set_state("ˇ"); + cx.update_editor(|e, window, cx| e.insert("hello", window, cx)); + // cx.assert_editor_state("helloˇ"); + + // Paste some text immediately after typing + cx.write_to_clipboard(ClipboardItem::new_string(" world".into())); + cx.update_editor(|e, window, cx| e.paste(&Paste, window, cx)); + cx.assert_editor_state("hello worldˇ"); + + // Undo should only undo the paste, not the preceding typing + cx.update_editor(|e, window, cx| e.undo(&Undo, window, cx)); + cx.assert_editor_state("helloˇ"); + + // Undo again should undo the typing + cx.update_editor(|e, window, cx| e.undo(&Undo, window, cx)); + cx.assert_editor_state("ˇ"); +} + +#[gpui::test] +async fn test_paste_content_from_other_app(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + cx.write_to_clipboard(ClipboardItem::new_string( + " d(\n e\n );\n".into(), + )); + + let mut cx = EditorTestContext::new(cx).await; + cx.update_buffer(|buffer, cx| buffer.set_language(Some(rust_lang()), cx)); + cx.run_until_parked(); + + cx.set_state(indoc! {" + fn a() { + b(); + if c() { + ˇ + } + } + "}); + + cx.update_editor(|e, window, cx| e.paste(&Paste, window, cx)); + cx.assert_editor_state(indoc! {" + fn a() { + b(); + if c() { + d( + e + ); + ˇ + } + } + "}); + + cx.set_state(indoc! {" + fn a() { + b(); + ˇ + } + "}); + + cx.update_editor(|e, window, cx| e.paste(&Paste, window, cx)); + cx.assert_editor_state(indoc! {" + fn a() { + b(); + d( + e + ); + ˇ + } + "}); +} + +#[gpui::test] +async fn test_paste_after_closing_documentation_comment(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + cx.write_to_clipboard(ClipboardItem::new_string( + "func find() {\n\treturn 1\n}\n".into(), + )); + + let mut cx = EditorTestContext::new(cx).await; + cx.update_buffer(|buffer, cx| { + buffer.set_language( + Some(languages::language("go", tree_sitter_go::LANGUAGE.into())), + cx, + ) + }); + cx.set_state("package test\n\n/**\n * test\n */ˇ"); + cx.update_editor(|editor, window, cx| editor.newline(&Newline, window, cx)); + cx.update_editor(|editor, window, cx| editor.paste(&Paste, window, cx)); + cx.assert_editor_state("package test\n\n/**\n * test\n */\nfunc find() {\n\treturn 1\n}\nˇ"); + + cx.write_to_clipboard(ClipboardItem::new_string( + " func find() {\n \treturn 1\n }".into(), + )); + cx.set_state("package test\n\n/**\n * test\n */ˇ"); + cx.update_editor(|editor, window, cx| editor.newline(&Newline, window, cx)); + cx.update_editor(|editor, window, cx| editor.paste(&Paste, window, cx)); + cx.assert_editor_state("package test\n\n/**\n * test\n */\nfunc find() {\n\treturn 1\n}ˇ"); +} + +#[gpui::test] +async fn test_paste_shifts_block_by_first_line_delta(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + cx.update_buffer(|buffer, cx| { + buffer.set_language( + Some(languages::language("go", tree_sitter_go::LANGUAGE.into())), + cx, + ) + }); + + cx.write_to_clipboard(ClipboardItem::new_string( + " func find() {\n \treturn 1\n }".into(), + )); + cx.set_state("package test\n\nˇ"); + cx.update_editor(|editor, window, cx| editor.paste(&Paste, window, cx)); + cx.run_until_parked(); + cx.assert_editor_state("package test\n\nfunc find() {\n\treturn 1\n}ˇ"); + + // The block moves by however far its first line moved, so a first line that + // is indented deeper than the lines below it stays deeper. + cx.write_to_clipboard(ClipboardItem::new_string(" foo()\n bar()".into())); + cx.set_state("func test() {\nˇ\n}"); + cx.update_editor(|editor, window, cx| editor.paste(&Paste, window, cx)); + cx.run_until_parked(); + cx.assert_editor_state("func test() {\n foo()\nbar()ˇ\n}"); +} + +#[gpui::test] +async fn test_paste_after_closing_comment_delimiter(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + cx.update_buffer(|buffer, cx| { + buffer.set_language( + Some(languages::language("go", tree_sitter_go::LANGUAGE.into())), + cx, + ) + }); + + // The closing delimiter's line is not part of the paste, so it keeps its own + // indentation even though that indentation differs from the opening line's. + cx.write_to_clipboard(ClipboardItem::new_string("x := 1\ny := 2".into())); + cx.set_state("package test\n\n/*\n * test\n */ˇ"); + cx.update_editor(|editor, window, cx| editor.paste(&Paste, window, cx)); + cx.assert_editor_state("package test\n\n/*\n * test\n */x := 1\n y := 2ˇ"); +} + +#[gpui::test] +async fn test_paste_multiline_from_other_app_into_matching_cursors(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + cx.write_to_clipboard(ClipboardItem::new_string("alpha\nbeta\ngamma".into())); + + let mut cx = EditorTestContext::new(cx).await; + + // Paste into 3 cursors: each cursor should receive one line. + cx.set_state("ˇ one ˇ two ˇ three"); + cx.update_editor(|e, window, cx| e.paste(&Paste, window, cx)); + cx.assert_editor_state("alphaˇ one betaˇ two gammaˇ three"); + + // Paste into 2 cursors: line count doesn't match, so paste entire text at each cursor. + cx.write_to_clipboard(ClipboardItem::new_string("alpha\nbeta\ngamma".into())); + cx.set_state("ˇ one ˇ two"); + cx.update_editor(|e, window, cx| e.paste(&Paste, window, cx)); + cx.assert_editor_state("alpha\nbeta\ngammaˇ one alpha\nbeta\ngammaˇ two"); + + // Paste into a single cursor: should paste everything as-is. + cx.write_to_clipboard(ClipboardItem::new_string("alpha\nbeta\ngamma".into())); + cx.set_state("ˇ one"); + cx.update_editor(|e, window, cx| e.paste(&Paste, window, cx)); + cx.assert_editor_state("alpha\nbeta\ngammaˇ one"); + + // Paste with selections: each selection is replaced with its corresponding line. + cx.write_to_clipboard(ClipboardItem::new_string("xx\nyy\nzz".into())); + cx.set_state("«aˇ» one «bˇ» two «cˇ» three"); + cx.update_editor(|e, window, cx| e.paste(&Paste, window, cx)); + cx.assert_editor_state("xxˇ one yyˇ two zzˇ three"); +} + +#[gpui::test] +fn test_select_all(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let editor = cx.add_window(|window, cx| { + let buffer = MultiBuffer::build_simple("abc\nde\nfgh", cx); + build_editor(buffer, window, cx) + }); + _ = editor.update(cx, |editor, window, cx| { + editor.select_all(&SelectAll, window, cx); + assert_eq!( + display_ranges(editor, cx), + &[DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(2), 3)] + ); + }); +} + +#[gpui::test] +fn test_select_line(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let editor = cx.add_window(|window, cx| { + let buffer = MultiBuffer::build_simple(&sample_text(6, 5, 'a'), cx); + build_editor(buffer, window, cx) + }); + _ = editor.update(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 1), + DisplayPoint::new(DisplayRow(0), 2)..DisplayPoint::new(DisplayRow(0), 2), + DisplayPoint::new(DisplayRow(1), 0)..DisplayPoint::new(DisplayRow(1), 0), + DisplayPoint::new(DisplayRow(4), 2)..DisplayPoint::new(DisplayRow(4), 2), + ]) + }); + editor.select_line(&SelectLine, window, cx); + // Adjacent line selections should NOT merge (only overlapping ones do) + assert_eq!( + display_ranges(editor, cx), + vec![ + DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(1), 0), + DisplayPoint::new(DisplayRow(1), 0)..DisplayPoint::new(DisplayRow(2), 0), + DisplayPoint::new(DisplayRow(4), 0)..DisplayPoint::new(DisplayRow(5), 0), + ] + ); + }); + + _ = editor.update(cx, |editor, window, cx| { + editor.select_line(&SelectLine, window, cx); + assert_eq!( + display_ranges(editor, cx), + vec![ + DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(3), 0), + DisplayPoint::new(DisplayRow(4), 0)..DisplayPoint::new(DisplayRow(5), 5), + ] + ); + }); + + _ = editor.update(cx, |editor, window, cx| { + editor.select_line(&SelectLine, window, cx); + // Adjacent but not overlapping, so they stay separate + assert_eq!( + display_ranges(editor, cx), + vec![ + DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(4), 0), + DisplayPoint::new(DisplayRow(4), 0)..DisplayPoint::new(DisplayRow(5), 5), + ] + ); + }); +} + +#[gpui::test] +async fn test_split_selection_into_lines(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + + #[track_caller] + fn test(cx: &mut EditorTestContext, initial_state: &'static str, expected_state: &'static str) { + cx.set_state(initial_state); + cx.update_editor(|e, window, cx| { + e.split_selection_into_lines(&Default::default(), window, cx) + }); + cx.assert_editor_state(expected_state); + } + + // Selection starts and ends at the middle of lines, left-to-right + test( + &mut cx, + "aa\nb«ˇb\ncc\ndd\ne»e\nff", + "aa\nbbˇ\nccˇ\nddˇ\neˇe\nff", + ); + // Same thing, right-to-left + test( + &mut cx, + "aa\nb«b\ncc\ndd\neˇ»e\nff", + "aa\nbbˇ\nccˇ\nddˇ\neˇe\nff", + ); + + // Whole buffer, left-to-right, last line *doesn't* end with newline + test( + &mut cx, + "«ˇaa\nbb\ncc\ndd\nee\nff»", + "aaˇ\nbbˇ\nccˇ\nddˇ\neeˇ\nffˇ", + ); + // Same thing, right-to-left + test( + &mut cx, + "«aa\nbb\ncc\ndd\nee\nffˇ»", + "aaˇ\nbbˇ\nccˇ\nddˇ\neeˇ\nffˇ", + ); + + // Whole buffer, left-to-right, last line ends with newline + test( + &mut cx, + "«ˇaa\nbb\ncc\ndd\nee\nff\n»", + "aaˇ\nbbˇ\nccˇ\nddˇ\neeˇ\nffˇ\n", + ); + // Same thing, right-to-left + test( + &mut cx, + "«aa\nbb\ncc\ndd\nee\nff\nˇ»", + "aaˇ\nbbˇ\nccˇ\nddˇ\neeˇ\nffˇ\n", + ); + + // Starts at the end of a line, ends at the start of another + test( + &mut cx, + "aa\nbb«ˇ\ncc\ndd\nee\n»ff\n", + "aa\nbbˇ\nccˇ\nddˇ\neeˇ\nff\n", + ); +} + +#[gpui::test] +async fn test_split_selection_into_lines_does_not_scroll(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + + let large_body = "\nline".repeat(300); + cx.set_state(&format!("«ˇstart{large_body}\nend»")); + let initial_scroll_position = cx.update_editor(|editor, _, cx| editor.scroll_position(cx)); + + cx.update_editor(|editor, window, cx| { + editor.split_selection_into_lines(&Default::default(), window, cx); + }); + + let scroll_position_after_split = cx.update_editor(|editor, _, cx| editor.scroll_position(cx)); + assert_eq!( + initial_scroll_position, scroll_position_after_split, + "Scroll position should not change after splitting selection into lines" + ); +} + +#[gpui::test] +async fn test_split_selection_into_lines_interacting_with_creases(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let editor = cx.add_window(|window, cx| { + let buffer = MultiBuffer::build_simple(&sample_text(9, 5, 'a'), cx); + build_editor(buffer, window, cx) + }); + + // setup + _ = editor.update(cx, |editor, window, cx| { + editor.fold_creases( + vec![ + Crease::simple(Point::new(0, 2)..Point::new(1, 2), FoldPlaceholder::test()), + Crease::simple(Point::new(2, 3)..Point::new(4, 1), FoldPlaceholder::test()), + Crease::simple(Point::new(7, 0)..Point::new(8, 4), FoldPlaceholder::test()), + ], + true, + window, + cx, + ); + assert_eq!( + editor.display_text(cx), + "aa⋯bbb\nccc⋯eeee\nfffff\nggggg\n⋯i" + ); + }); + + _ = editor.update(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 1), + DisplayPoint::new(DisplayRow(0), 2)..DisplayPoint::new(DisplayRow(0), 2), + DisplayPoint::new(DisplayRow(1), 0)..DisplayPoint::new(DisplayRow(1), 0), + DisplayPoint::new(DisplayRow(4), 4)..DisplayPoint::new(DisplayRow(4), 4), + ]) + }); + editor.split_selection_into_lines(&Default::default(), window, cx); + assert_eq!( + editor.display_text(cx), + "aaaaa\nbbbbb\nccc⋯eeee\nfffff\nggggg\n⋯i" + ); + }); + EditorTestContext::for_editor(editor, cx) + .await + .assert_editor_state("aˇaˇaaa\nbbbbb\nˇccccc\nddddd\neeeee\nfffff\nggggg\nhhhhh\niiiiiˇ"); + + _ = editor.update(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(5), 0)..DisplayPoint::new(DisplayRow(0), 1) + ]) + }); + editor.split_selection_into_lines(&Default::default(), window, cx); + assert_eq!( + editor.display_text(cx), + "aaaaa\nbbbbb\nccccc\nddddd\neeeee\nfffff\nggggg\nhhhhh\niiiii" + ); + assert_eq!( + display_ranges(editor, cx), + [ + DisplayPoint::new(DisplayRow(0), 5)..DisplayPoint::new(DisplayRow(0), 5), + DisplayPoint::new(DisplayRow(1), 5)..DisplayPoint::new(DisplayRow(1), 5), + DisplayPoint::new(DisplayRow(2), 5)..DisplayPoint::new(DisplayRow(2), 5), + DisplayPoint::new(DisplayRow(3), 5)..DisplayPoint::new(DisplayRow(3), 5), + DisplayPoint::new(DisplayRow(4), 5)..DisplayPoint::new(DisplayRow(4), 5), + DisplayPoint::new(DisplayRow(5), 5)..DisplayPoint::new(DisplayRow(5), 5), + DisplayPoint::new(DisplayRow(6), 5)..DisplayPoint::new(DisplayRow(6), 5) + ] + ); + }); + EditorTestContext::for_editor(editor, cx) + .await + .assert_editor_state( + "aaaaaˇ\nbbbbbˇ\ncccccˇ\ndddddˇ\neeeeeˇ\nfffffˇ\ngggggˇ\nhhhhh\niiiii", + ); +} + +#[gpui::test] +async fn test_add_selection_grapheme_columns(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + for (text, columns) in [ + ("e\u{301}x|Z\néé|Zˇ", [4..4, 4..4]), + ("e\u{301}x|Z\néé|Zˇ", [4..5, 4..5]), + ("éx|Z\ne\u{301}x|Zˇ", [3..3, 4..4]), + ("👩\u{200d}💻x|Z\nax|Zˇ", [12..13, 2..3]), + ("🇦🇶x|Z\nax|Zˇ", [8..9, 1..2]), + ("\t\u{301}x\n\t\u{301}xˇ", [1..1, 1..1]), + ("\t\u{301}x\n\t\u{301}xˇ", [1..3, 1..3]), + ] { + for above in [false, true] { + for reversed in [false, true] { + cx.set_state(text); + cx.update_editor(|editor, window, cx| { + assert_add_selection_pair(editor, columns.clone(), above, reversed, window, cx); + }); + } + } + } +} + +#[gpui::test] +async fn test_add_selection_goal_after_moving(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + cx.set_state("abˇcdef\nabcdef\nabcdef"); + cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, false, true, window, cx); + editor.move_right(&MoveRight, window, cx); + add_selection_in_direction(editor, false, true, window, cx); + }); + cx.assert_editor_state("abcˇdef\nabcˇdef\nabcˇdef"); +} + +#[gpui::test] +async fn test_add_selection_unwrapped_unprojectable_sources(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + for above in [false, true] { + for reversed in [false, true] { + for mixed in [false, true] { + cx.set_state("aˇ\nb\nabcdef\nabcdef"); + cx.update_editor(|editor, window, cx| { + let position = editor + .buffer + .read(cx) + .snapshot(cx) + .anchor_before(Point::new(1, 0)); + editor.splice_inlays( + &[], + vec![inlays::Inlay::mock_hint(0, position, "HH")], + cx, + ); + let source = if reversed { + Point::new(1, 0)..Point::new(0, 1) + } else { + Point::new(0, 1)..Point::new(1, 0) + }; + let other_row = if above { 3 } else { 2 }; + let mut original = vec![source.clone()]; + if mixed { + original.push(Point::new(other_row, 1)..Point::new(other_row, 1)); + } + editor.change_selections( + SelectionEffects::no_scroll(), + window, + cx, + |selections| selections.select_ranges(original.clone()), + ); + let original_id = editor.selections.first_anchor().id; + add_selection_in_direction(editor, above, true, window, cx); + let mut expected = vec![source]; + if mixed { + expected.extend([ + Point::new(2, 1)..Point::new(2, 1), + Point::new(3, 1)..Point::new(3, 1), + ]); + } + assert_add_selection_ranges(editor, &expected, cx); + assert_eq!(editor.selections.first_anchor().id, original_id); + add_selection_in_direction(editor, !above, true, window, cx); + assert_add_selection_ranges(editor, &original, cx); + assert_eq!(editor.selections.first_anchor().id, original_id); + editor.splice_inlays(&[project::InlayId::Hint(0)], Vec::new(), cx); + }); + } + } + } +} + +#[gpui::test] +async fn test_add_selection_unwrapped_multiline_goal_and_reversal(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + for (above, text, source, columns) in [ + ( + false, + "abc\ndefghi\nuvwxyz\nabcdefˇ", + Point::new(0, 1)..Point::new(1, 4), + [1..3, 1..4, 1..4, 1..4], + ), + ( + true, + "abcdef\nuvwxyz\ndefghi\nabcˇ", + Point::new(2, 4)..Point::new(3, 1), + [1..4, 1..4, 1..4, 1..3], + ), + ] { + for reversed in [false, true] { + for deferred in [false, true] { + cx.set_state(text); + cx.update_editor(|editor, window, cx| { + editor.change_selections( + SelectionEffects::no_scroll(), + window, + cx, + |selections| { + selections.select_ranges([if reversed { + source.end..source.start + } else { + source.clone() + }]); + }, + ); + for (direction, count) in [ + (above, 3), + (above, 4), + (!above, 3), + (above, 4), + (!above, 3), + (!above, 2), + (!above, 1), + (!above, 1), + (above, 2), + ] { + if deferred { + editor.with_selection_effects_deferred( + window, + cx, + |editor, window, cx| { + add_selection_in_direction(editor, direction, true, window, cx); + }, + ); + } else { + add_selection_in_direction(editor, direction, true, window, cx); + } + let expected = columns + .iter() + .enumerate() + .filter(|(row, _)| { + if above { + *row >= 4 - count + } else { + *row < count + } + }) + .map(|(row, columns)| { + let start = Point::new(row as u32, columns.start); + let end = Point::new(row as u32, columns.end); + if reversed { end..start } else { start..end } + }) + .collect::>(); + assert_add_selection_ranges(editor, &expected, cx); + } + }); + } + } + } +} + +#[gpui::test] +async fn test_add_selection_unwrapped_single_projection_retains_goal(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + cx.set_state("a«bc\nˇ»\nabcdef"); + cx.update_editor(|editor, window, cx| { + let position = editor + .buffer + .read(cx) + .snapshot(cx) + .anchor_before(Point::new(1, 0)); + editor.splice_inlays(&[], vec![inlays::Inlay::mock_hint(0, position, "HHHH")], cx); + editor.with_selection_effects_deferred(window, cx, |editor, window, cx| { + add_selection_in_direction(editor, true, true, window, cx); + }); + }); + cx.assert_editor_state("a«bcˇ»\n\nabcdef"); + for _ in 0..2 { + cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, false, true, window, cx); + }); + cx.assert_editor_state("a«bcˇ»\n\na«bcdˇ»ef"); + cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, true, true, window, cx); + }); + cx.assert_editor_state("a«bcˇ»\n\nabcdef"); + } +} + +#[gpui::test] +async fn test_add_selection_unwrapped_multiline_goal_after_moving_or_typing( + cx: &mut TestAppContext, +) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + for typing in [false, true] { + cx.set_state("a«bc\ndefgˇ»hi\nuvwxyz\nabcdef"); + cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, false, true, window, cx); + if typing { + editor.handle_input("X", window, cx); + } else { + editor.move_right(&MoveRight, window, cx); + } + add_selection_in_direction(editor, false, true, window, cx); + }); + let expected = if typing { + "aXˇ\ndXˇhi\nuXˇyz\nabˇcdef" + } else { + "abcˇ\ndefgˇhi\nuvwxˇyz\nabcˇdef" + }; + cx.assert_editor_state(expected); + cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, true, true, window, cx); + add_selection_in_direction(editor, false, true, window, cx); + }); + cx.assert_editor_state(expected); + } +} + +#[gpui::test] +fn test_add_selection_unwrapped_reseeds_after_folding_source_buffer(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new_multibuffer(cx, ["«abcdef»", "«x\nabcdef\nabcdef»"]); + let source_buffer_id = cx.multibuffer(|buffer, cx| { + buffer + .snapshot(cx) + .excerpts() + .next() + .expect("source excerpt") + .context + .start + .buffer_id + }); + cx.set_selections_state("abcdˇef\nx\nabcdef\nabcdef"); + cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, false, true, window, cx); + add_selection_in_direction(editor, false, true, window, cx); + }); + cx.assert_editor_state("abcdˇef\nxˇ\nabcdˇef\nabcdef"); + cx.update_editor(|editor, _, cx| { + editor.fold_buffer(source_buffer_id, cx); + }); + cx.assert_editor_state("abcdef\nxˇ\nabcdˇef\nabcdef"); + cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, false, true, window, cx); + }); + cx.assert_editor_state("abcdef\nxˇ\nabcdˇef\naˇbcdef"); + cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, true, true, window, cx); + add_selection_in_direction(editor, false, true, window, cx); + }); + cx.assert_editor_state("abcdef\nxˇ\nabcdˇef\naˇbcdef"); +} + +#[gpui::test] +fn test_add_selection_unwrapped_retains_goal_after_folding_projection_buffer( + cx: &mut TestAppContext, +) { + init_test(cx, |_| {}); + for above in [false, true] { + let mut cx = EditorTestContext::new_multibuffer( + cx, + if above { + ["«abcdef»", "«uvwxyz»", "«defghi\nabc»"] + } else { + ["«abc\ndefghi»", "«uvwxyz»", "«abcdef»"] + }, + ); + let projection_buffer_id = cx.multibuffer(|buffer, cx| { + buffer + .snapshot(cx) + .excerpts() + .nth(1) + .expect("projection excerpt") + .context + .start + .buffer_id + }); + cx.set_selections_state(if above { + "abcdef\nuvwxyz\ndefg«hi\naˇ»bc" + } else { + "a«bc\ndefgˇ»hi\nuvwxyz\nabcdef" + }); + cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, above, true, window, cx); + }); + cx.assert_editor_state(if above { + "abcdef\nu«vwxˇ»yz\nd«efgˇ»hi\na«bcˇ»" + } else { + "a«bcˇ»\nd«efgˇ»hi\nu«vwxˇ»yz\nabcdef" + }); + cx.update_editor(|editor, _, cx| { + editor.fold_buffer(projection_buffer_id, cx); + }); + cx.assert_editor_state(if above { + "abcdef\nuvwxyz\nd«efgˇ»hi\na«bcˇ»" + } else { + "a«bcˇ»\nd«efgˇ»hi\nuvwxyz\nabcdef" + }); + for _ in 0..2 { + cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, above, true, window, cx); + }); + cx.assert_editor_state(if above { + "a«bcdˇ»ef\nuvwxyz\nd«efgˇ»hi\na«bcˇ»" + } else { + "a«bcˇ»\nd«efgˇ»hi\nuvwxyz\na«bcdˇ»ef" + }); + cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, !above, true, window, cx); + }); + } + } +} + +#[gpui::test] +async fn test_add_selection_reverses_groups_with_deleted_sources(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + for skip_soft_wrap in [false, true] { + cx.set_state("abˇcdef\nabcdef\nabcdef\nabˇcdef\nabcdef\nabcdef"); + cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, false, skip_soft_wrap, window, cx); + }); + cx.assert_editor_state("abˇcdef\nabˇcdef\nabcdef\nabˇcdef\nabˇcdef\nabcdef"); + cx.update_editor(|editor, window, cx| { + let snapshot = editor.display_snapshot(cx); + let source_ids = editor + .selections + .all::(&snapshot) + .into_iter() + .filter(|selection| selection.start.row % 3 == 0) + .map(|selection| selection.id) + .collect::>(); + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| { + for id in source_ids { + selections.delete(id); + } + }); + add_selection_in_direction(editor, true, skip_soft_wrap, window, cx); + }); + cx.assert_editor_state("abˇcdef\nabˇcdef\nabcdef\nabˇcdef\nabˇcdef\nabcdef"); + } +} + +#[gpui::test] +async fn test_add_selection_after_members_span_rows(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + for skip_soft_wrap in [false, true] { + cx.set_state("abˇcdef\nabcdef\nabcdef\nabcdef"); + cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, false, skip_soft_wrap, window, cx); + editor.select_down(&SelectDown, window, cx); + }); + cx.assert_editor_state("ab«cdef\nabˇ»«cdef\nabˇ»cdef\nabcdef"); + cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, false, skip_soft_wrap, window, cx); + }); + cx.assert_editor_state("ab«cdef\nabˇ»«cdef\nabˇ»cdef\nabˇcdef"); + } +} + +#[gpui::test] +async fn test_add_selection_fresh_pixel_source_reseeds_goal(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + for above in [false, true] { + cx.set_state(if above { + "abcdef\nx\nabcdˇef" + } else { + "abcdˇef\nx\nabcdef" + }); + cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, above, false, window, cx); + }); + cx.assert_editor_state(if above { + "abcdef\nxˇ\nabcdˇef" + } else { + "abcdˇef\nxˇ\nabcdef" + }); + cx.update_editor(|editor, window, cx| { + let source_row = if above { 2 } else { 0 }; + let source_id = editor + .selections + .disjoint_anchors() + .iter() + .find(|selection| { + selection + .start + .to_point(&editor.buffer.read(cx).snapshot(cx)) + .row + == source_row + }) + .expect("source selection") + .id; + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| { + selections.delete(source_id); + }); + let clamped_id = editor.selections.disjoint_anchors()[0].id; + add_selection_in_direction(editor, above, false, window, cx); + assert_eq!( + stored_selection_ranges(editor, cx), + if above { + [ + Point::new(0, 1)..Point::new(0, 1), + Point::new(1, 1)..Point::new(1, 1), + ] + } else { + [ + Point::new(1, 1)..Point::new(1, 1), + Point::new(2, 1)..Point::new(2, 1), + ] + } + ); + assert!( + editor + .selections + .disjoint_anchors() + .iter() + .any(|selection| selection.id == clamped_id) + ); + }); + } +} + +#[gpui::test] +async fn test_add_selection_keeps_fresh_source_inside_fold(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + for skip_soft_wrap in [false, true] { + cx.set_state("abˇcdef\nabcdef\nabcdef"); + cx.update_editor(|editor, window, cx| { + editor.fold_creases( + vec![Crease::simple( + Point::new(0, 0)..Point::new(1, 6), + FoldPlaceholder::test(), + )], + false, + window, + cx, + ); + assert_eq!(editor.display_text(cx), "⋯\nabcdef"); + let source_id = editor.selections.disjoint_anchors()[0].id; + add_selection_in_direction(editor, false, skip_soft_wrap, window, cx); + let stored = stored_selection_ranges(editor, cx); + assert_eq!(stored[0], Point::new(0, 2)..Point::new(0, 2)); + assert_eq!(stored.len(), 2); + assert_eq!(stored[1].start.row, 2); + assert_eq!(editor.selections.disjoint_anchors()[0].id, source_id); + add_selection_in_direction(editor, true, skip_soft_wrap, window, cx); + assert_eq!( + stored_selection_ranges(editor, cx), + [Point::new(0, 2)..Point::new(0, 2)] + ); + assert_eq!(editor.selections.disjoint_anchors()[0].id, source_id); + }); + } +} + +#[gpui::test] +async fn test_add_selection_keeps_group_members_coalesced_by_fold(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + for skip_soft_wrap in [false, true] { + cx.set_state("abˇcdef\nabcdef\nabcdef"); + cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, false, skip_soft_wrap, window, cx); + editor.fold_creases( + vec![Crease::simple( + Point::new(0, 0)..Point::new(1, 6), + FoldPlaceholder::test(), + )], + false, + window, + cx, + ); + assert_eq!(editor.display_text(cx), "⋯\nabcdef"); + add_selection_in_direction(editor, false, skip_soft_wrap, window, cx); + let stored = stored_selection_ranges(editor, cx); + assert_eq!( + stored[..2], + [ + Point::new(0, 2)..Point::new(0, 2), + Point::new(1, 2)..Point::new(1, 2), + ] + ); + assert_eq!(stored.len(), 3); + assert_eq!(stored[2].start.row, 2); + add_selection_in_direction(editor, true, skip_soft_wrap, window, cx); + add_selection_in_direction(editor, true, skip_soft_wrap, window, cx); + assert_eq!( + stored_selection_ranges(editor, cx), + [Point::new(0, 2)..Point::new(0, 2)] + ); + }); + } +} + +#[gpui::test] +fn test_add_selection_keeps_sources_on_block_rows(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + for skip_soft_wrap in [false, true] { + for visible_source in [None, Some(false), Some(true)] { + let mut cx = EditorTestContext::new_multibuffer(cx, ["«abcdef»", "«abcdef\nabcdef»"]); + let buffer_ids = cx.multibuffer(|buffer, cx| { + buffer + .snapshot(cx) + .excerpts() + .map(|excerpt| excerpt.context.start.buffer_id) + .collect::>() + }); + let folded = if visible_source.is_some() { + &buffer_ids[..1] + } else { + &buffer_ids[..] + }; + cx.set_selections_state("abˇcdef\nabcdef\nabcdef"); + cx.update_editor(|editor, _, cx| { + editor.fold_buffers(folded.iter().copied(), cx); + }); + let mut expected = vec![Point::zero()..Point::zero()]; + cx.update_editor(|editor, window, cx| { + assert_eq!(stored_selection_ranges(editor, cx), expected); + if let Some(pending) = visible_source { + expected.push(Point::new(1, 2)..Point::new(1, 2)); + if pending { + let position = + Point::new(1, 2).to_display_point(&editor.display_snapshot(cx)); + editor.begin_selection(position, true, 1, window, cx); + assert!(editor.selections.pending_anchor().is_some()); + } else { + editor.change_selections( + SelectionEffects::no_scroll(), + window, + cx, + |selections| { + selections.select_ranges(expected.clone()); + }, + ); + } + assert_eq!(stored_selection_ranges(editor, cx), expected); + } + add_selection_in_direction(editor, false, skip_soft_wrap, window, cx); + if visible_source.is_some() { + expected.push(Point::new(2, 2)..Point::new(2, 2)); + } + assert_eq!(stored_selection_ranges(editor, cx), expected); + add_selection_in_direction(editor, true, skip_soft_wrap, window, cx); + if visible_source.is_some() { + expected.pop(); + } + assert_eq!(stored_selection_ranges(editor, cx), expected); + editor.handle_input("X", window, cx); + }); + cx.update_editor(|editor, _, cx| { + for buffer_id in folded { + editor.unfold_buffer(*buffer_id, cx); + } + }); + cx.assert_editor_state(if visible_source.is_some() { + "Xˇabcdef\nabXˇcdef\nabcdef" + } else { + "Xˇabcdef\nabcdef\nabcdef" + }); + } + } +} + +#[gpui::test] +fn test_add_selection_skips_block_rows(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new_multibuffer( + cx, + ["«abcdef»", "«abcdefghij\nx»", "«abcdef»", "«abcdefghij\nx»"], + ); + let folded_buffer_ids = cx.multibuffer(|buffer, cx| { + buffer + .snapshot(cx) + .excerpts() + .map(|excerpt| excerpt.context.start.buffer_id) + .skip(1) + .step_by(2) + .collect::>() + }); + for skip_soft_wrap in [false, true] { + for column in [0, 4] { + let source = Point::new(0, column)..Point::new(0, column); + cx.update_editor(|editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| { + selections.select_ranges([source.clone()]); + }); + add_selection_in_direction(editor, false, skip_soft_wrap, window, cx); + assert_add_selection_ranges( + editor, + &[source.clone(), Point::new(1, column)..Point::new(1, column)], + cx, + ); + }); + } + } + cx.update_editor(|editor, _, cx| { + editor.fold_buffers(folded_buffer_ids, cx); + }); + for skip_soft_wrap in [false, true] { + for (column, above) in [(0, false), (4, false), (0, true), (4, true)] { + let source_row = if above { 3 } else { 0 }; + let source = Point::new(source_row, column)..Point::new(source_row, column); + let target = Point::new(3 - source_row, column)..Point::new(3 - source_row, column); + cx.update_editor(|editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| { + selections.select_ranges([source.clone()]); + }); + add_selection_in_direction(editor, above, skip_soft_wrap, window, cx); + let mut expected = vec![source.clone(), target]; + expected.sort_by_key(|range| range.start); + assert_add_selection_ranges(editor, &expected, cx); + add_selection_in_direction(editor, above, skip_soft_wrap, window, cx); + assert_add_selection_ranges(editor, &expected, cx); + add_selection_in_direction(editor, !above, skip_soft_wrap, window, cx); + assert_add_selection_ranges(editor, std::slice::from_ref(&source), cx); + }); + } + } +} + +#[gpui::test] +async fn test_add_selection_history_restores_groups(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + for (above, initial, two, three) in [ + ( + false, + "abcdˇef\nab\nabcdef\nabcdef", + "abcdˇef\nabˇ\nabcdef\nabcdef", + "abcdˇef\nabˇ\nabcdˇef\nabcdef", + ), + ( + true, + "abcdef\nabcdef\nab\nabcdˇef", + "abcdef\nabcdef\nabˇ\nabcdˇef", + "abcdef\nabcdˇef\nabˇ\nabcdˇef", + ), + ] { + for skip_soft_wrap in [false, true] { + for deferred in [false, true] { + cx.set_state(initial); + cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, above, skip_soft_wrap, window, cx); + }); + cx.assert_editor_state(two); + cx.update_editor(|editor, window, cx| { + if deferred { + editor.with_selection_effects_deferred(window, cx, |editor, window, cx| { + add_selection_in_direction(editor, above, skip_soft_wrap, window, cx); + }); + } else { + add_selection_in_direction(editor, above, skip_soft_wrap, window, cx); + } + }); + cx.assert_editor_state(three); + cx.update_editor(|editor, window, cx| { + editor.undo_selection(&UndoSelection, window, cx); + }); + cx.assert_editor_state(two); + cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, above, skip_soft_wrap, window, cx); + }); + cx.assert_editor_state(three); + cx.update_editor(|editor, window, cx| { + editor.undo_selection(&UndoSelection, window, cx); + add_selection_in_direction(editor, !above, skip_soft_wrap, window, cx); + }); + cx.assert_editor_state(initial); + cx.update_editor(|editor, window, cx| { + editor.undo_selection(&UndoSelection, window, cx); + }); + cx.assert_editor_state(two); + cx.update_editor(|editor, window, cx| { + editor.redo_selection(&RedoSelection, window, cx); + }); + cx.assert_editor_state(initial); + cx.update_editor(|editor, window, cx| { + editor.undo_selection(&UndoSelection, window, cx); + add_selection_in_direction(editor, above, skip_soft_wrap, window, cx); + }); + cx.assert_editor_state(three); + } + } + } +} + +#[gpui::test] +async fn test_add_selection_history_deferred_batch(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + for skip_soft_wrap in [false, true] { + cx.set_state("abcdˇef\nab\nabcdef\nabcdef"); + cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, false, skip_soft_wrap, window, cx); + editor.with_selection_effects_deferred(window, cx, |editor, window, cx| { + add_selection_in_direction(editor, false, skip_soft_wrap, window, cx); + add_selection_in_direction(editor, false, skip_soft_wrap, window, cx); + }); + }); + cx.assert_editor_state("abcdˇef\nabˇ\nabcdˇef\nabcdˇef"); + cx.update_editor(|editor, window, cx| { + editor.undo_selection(&UndoSelection, window, cx); + }); + cx.assert_editor_state("abcdˇef\nabˇ\nabcdef\nabcdef"); + cx.update_editor(|editor, window, cx| { + editor.redo_selection(&RedoSelection, window, cx); + }); + cx.assert_editor_state("abcdˇef\nabˇ\nabcdˇef\nabcdˇef"); + cx.update_editor(|editor, window, cx| { + editor.undo_selection(&UndoSelection, window, cx); + add_selection_in_direction(editor, false, skip_soft_wrap, window, cx); + }); + cx.assert_editor_state("abcdˇef\nabˇ\nabcdˇef\nabcdef"); + } +} + +#[gpui::test] +async fn test_add_selection_history_deferred_external_movement(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + for move_first in [false, true] { + cx.set_state("abcdˇef\nab\nabcdef\nabcdef"); + cx.update_editor(|editor, window, cx| { + if move_first { + add_selection_in_direction(editor, false, true, window, cx); + } + editor.with_selection_effects_deferred(window, cx, |editor, window, cx| { + if !move_first { + add_selection_in_direction(editor, false, true, window, cx); + } + editor.move_right(&MoveRight, window, cx); + if move_first { + add_selection_in_direction(editor, false, true, window, cx); + } + }); + }); + if move_first { + cx.assert_editor_state("abcdeˇf\nab\nˇabcdef\nabcdeˇf"); + cx.update_editor(|editor, window, cx| { + editor.undo_selection(&UndoSelection, window, cx); + }); + cx.assert_editor_state("abcdˇef\nabˇ\nabcdef\nabcdef"); + cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, false, true, window, cx); + }); + cx.assert_editor_state("abcdˇef\nabˇ\nabcdˇef\nabcdef"); + } else { + cx.assert_editor_state("abcdeˇf\nab\nˇabcdef\nabcdef"); + cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, false, true, window, cx); + }); + cx.assert_editor_state("abcdeˇf\nab\nˇabcdef\nabcdeˇf"); + } + } +} + +#[gpui::test] +async fn test_add_selection_history_restores_mode_before_switch(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + for skip_soft_wrap in [false, true] { + for deferred in [false, true] { + cx.set_state("abcdˇef\nab\nabcdef\nabcdef"); + cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, false, skip_soft_wrap, window, cx); + if deferred { + editor.with_selection_effects_deferred(window, cx, |editor, window, cx| { + add_selection_in_direction(editor, false, !skip_soft_wrap, window, cx); + }); + } else { + add_selection_in_direction(editor, false, !skip_soft_wrap, window, cx); + } + }); + cx.assert_editor_state("abcdˇef\nabˇ\nabˇcdef\nabcdef"); + cx.update_editor(|editor, window, cx| { + editor.undo_selection(&UndoSelection, window, cx); + }); + cx.assert_editor_state("abcdˇef\nabˇ\nabcdef\nabcdef"); + cx.update_editor(|editor, window, cx| { + editor.redo_selection(&RedoSelection, window, cx); + }); + cx.assert_editor_state("abcdˇef\nabˇ\nabˇcdef\nabcdef"); + cx.update_editor(|editor, window, cx| { + editor.undo_selection(&UndoSelection, window, cx); + add_selection_in_direction(editor, false, skip_soft_wrap, window, cx); + }); + cx.assert_editor_state("abcdˇef\nabˇ\nabcdˇef\nabcdef"); + } + } +} + +#[gpui::test] +async fn test_add_selection_history_restores_unclipped_multiline_goal(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + for (above, initial, three, four) in multiline_add_selection_history_states() { + cx.set_state(initial); + cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, above, true, window, cx); + add_selection_in_direction(editor, above, true, window, cx); + }); + cx.assert_editor_state(four); + cx.update_editor(|editor, window, cx| { + editor.undo_selection(&UndoSelection, window, cx); + }); + cx.assert_editor_state(three); + cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, above, true, window, cx); + }); + cx.assert_editor_state(four); + cx.update_editor(|editor, window, cx| { + editor.undo_selection(&UndoSelection, window, cx); + editor.redo_selection(&RedoSelection, window, cx); + }); + cx.assert_editor_state(four); + } +} + +#[gpui::test] +async fn test_add_selection_text_undo_restores_multiline_goal(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + for (above, initial, three, four) in multiline_add_selection_history_states() { + for grouped in [false, true] { + cx.set_state(initial); + cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, above, true, window, cx); + editor.transact(window, cx, |editor, window, cx| { + editor.insert("X", window, cx); + if grouped { + editor.insert("Y", window, cx); + } + }); + editor.undo(&Undo, window, cx); + }); + cx.assert_editor_state(three); + cx.update_editor(|editor, window, cx| { + editor.redo(&Redo, window, cx); + editor.undo(&Undo, window, cx); + add_selection_in_direction(editor, above, true, window, cx); + }); + cx.assert_editor_state(four); + } + } +} + +#[gpui::test] +async fn test_add_selection_history_preserves_hidden_sources(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + for skip_soft_wrap in [false, true] { + for source_count in [1, 2] { + cx.set_state("abˇcdef\nabcdef\nabcdef"); + cx.update_editor(|editor, window, cx| { + let sources = (0..source_count) + .map(|row| Point::new(row, 2)..Point::new(row, 2)) + .collect::>(); + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| { + selections.select_ranges(sources.clone()); + }); + editor.fold_creases( + vec![Crease::simple( + Point::new(0, 0)..Point::new(1, 6), + FoldPlaceholder::test(), + )], + false, + window, + cx, + ); + add_selection_in_direction(editor, false, skip_soft_wrap, window, cx); + let mut added = sources.clone(); + added.push(Point::new(2, 0)..Point::new(2, 0)); + assert_eq!(stored_selection_ranges(editor, cx), added); + editor.undo_selection(&UndoSelection, window, cx); + assert_eq!(stored_selection_ranges(editor, cx), sources); + editor.redo_selection(&RedoSelection, window, cx); + assert_eq!(stored_selection_ranges(editor, cx), added); + editor.undo_selection(&UndoSelection, window, cx); + assert_eq!(stored_selection_ranges(editor, cx), sources); + }); + } + } +} + +#[gpui::test] +async fn test_add_selection_removing_projection_retains_goal(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + for remove_by_click in [false, true] { + cx.set_state("a«bc\ndefgˇ»hi\nuvwxyz"); + cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, false, true, window, cx); + }); + cx.assert_editor_state("a«bcˇ»\nd«efgˇ»hi\nu«vwxˇ»yz"); + cx.update_editor(|editor, window, cx| { + if remove_by_click { + editor.begin_selection(DisplayPoint::new(DisplayRow(2), 4), true, 1, window, cx); + } else { + add_selection_in_direction(editor, true, true, window, cx); + } + }); + cx.assert_editor_state("a«bcˇ»\nd«efgˇ»hi\nuvwxyz"); + cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, false, true, window, cx); + }); + cx.assert_editor_state("a«bcˇ»\nd«efgˇ»hi\nu«vwxˇ»yz"); + } +} + +#[gpui::test] +async fn test_add_selection_coalesced_source_reseeds_goal_and_history(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + cx.set_state("«abcdˇ»\nabcˇd\nx\nabcd"); + cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, false, true, window, cx); + }); + cx.assert_editor_state("«abcdˇ»\n«abcdˇ»\nxˇ\nabcd"); + cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, false, true, window, cx); + }); + cx.assert_editor_state("«abcdˇ»\n«abcdˇ»\n«xˇ»\naˇbcd"); + cx.update_editor(|editor, window, cx| { + editor.undo_selection(&UndoSelection, window, cx); + }); + cx.assert_editor_state("«abcdˇ»\n«abcdˇ»\nxˇ\nabcd"); + cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, false, true, window, cx); + }); + cx.assert_editor_state("«abcdˇ»\n«abcdˇ»\n«xˇ»\naˇbcd"); +} + +#[gpui::test] +async fn test_add_selection_replacement_discards_dead_groups_and_history(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + for skip_soft_wrap in [false, true] { + cx.set_state("aˇbcd\nabcd\nabcd"); + cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, false, skip_soft_wrap, window, cx); + add_selection_in_direction(editor, false, skip_soft_wrap, window, cx); + assert_eq!( + editor.add_selections_state.as_ref().map(|state| { + state + .groups + .iter() + .map(|group| group.stack.len()) + .collect::>() + }), + Some(vec![3]) + ); + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| { + selections.select_ranges([Point::new(0, 1)..Point::new(0, 1)]); + }); + assert!(editor.add_selections_state.is_none()); + editor.move_right(&MoveRight, window, cx); + editor.move_left(&MoveLeft, window, cx); + assert!(editor.add_selections_state.is_none()); + assert_eq!( + editor + .selection_history + .undo_stack + .iter() + .rev() + .take(2) + .map(|entry| entry + .add_selections_state + .as_ref() + .map(|state| state.groups.len())) + .collect::>(), + [None, None] + ); + editor.undo_selection(&UndoSelection, window, cx); + assert!(editor.add_selections_state.is_none()); + }); + cx.assert_editor_state("abˇcd\nabcd\nabcd"); + } +} + +#[gpui::test] +async fn test_add_selection_cancel_reseeds_goal(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + for (above, initial, cancelled, restarted) in [ + ( + false, + "a«bc\ndefgˇ»hi\nuvwxyz", + "a«bcˇ»\ndefghi\nuvwxyz", + "a«bcˇ»\nd«efˇ»ghi\nuvwxyz", + ), + ( + true, + "uvwxyz\na«bc\ndefgˇ»hi", + "uvwxyz\na«bcˇ»\ndefghi", + "u«vwˇ»xyz\na«bcˇ»\ndefghi", + ), + ] { + for skip_soft_wrap in [false, true] { + cx.set_state(initial); + let grouped = cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, above, skip_soft_wrap, window, cx); + let grouped = editor + .selections + .ranges::(&editor.display_snapshot(cx)); + editor.cancel(&Cancel, window, cx); + grouped + }); + cx.assert_editor_state(cancelled); + cx.update_editor(|editor, window, cx| { + editor.undo_selection(&UndoSelection, window, cx); + add_selection_in_direction(editor, !above, skip_soft_wrap, window, cx); + add_selection_in_direction(editor, above, skip_soft_wrap, window, cx); + assert_add_selection_ranges(editor, &grouped, cx); + editor.cancel(&Cancel, window, cx); + editor.undo_selection(&UndoSelection, window, cx); + editor.redo_selection(&RedoSelection, window, cx); + add_selection_in_direction(editor, above, skip_soft_wrap, window, cx); + }); + cx.assert_editor_state(restarted); + } + } +} + +#[gpui::test] +async fn test_add_selection_unrelated_pending_retains_goal(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + for (finish_pending, cancel_pending) in [(false, false), (true, false), (false, true)] { + cx.set_state("a«bc\ndefgˇ»hi\nuvwxyz\nabcdef\n012345"); + cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, false, true, window, cx); + editor.begin_selection(DisplayPoint::new(DisplayRow(4), 0), true, 1, window, cx); + if finish_pending { + editor.end_selection(window, cx); + } else if cancel_pending { + editor.cancel(&Cancel, window, cx); + } + add_selection_in_direction(editor, false, true, window, cx); + }); + let caret = if cancel_pending { "" } else { "ˇ" }; + cx.assert_editor_state(&format!( + "a«bcˇ»\nd«efgˇ»hi\nu«vwxˇ»yz\na«bcdˇ»ef\n{caret}012345" + )); + } +} + +#[gpui::test] +async fn test_add_selection_pending_overlap_cancel_reverses_group(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + for skip_soft_wrap in [false, true] { + for above in [false, true] { + for overlap_source in [false, true] { + for restore_history in [false, true] { + cx.set_state("abcdef\nabcdef\nabcdef\nabcdefˇ"); + cx.update_editor(|editor, window, cx| { + let source_row = if above { 2 } else { 1 }; + let added_row = if above { 1 } else { 2 }; + let source = Point::new(source_row, 2); + editor.change_selections( + SelectionEffects::no_scroll(), + window, + cx, + |selections| { + selections.select_ranges([source..source]); + }, + ); + add_selection_in_direction(editor, above, skip_soft_wrap, window, cx); + begin_add_selection_overlap( + editor, + if overlap_source { + source_row + } else { + added_row + }, + 0..3, + window, + cx, + ); + editor.cancel(&Cancel, window, cx); + if restore_history { + editor.undo_selection(&UndoSelection, window, cx); + editor.redo_selection(&RedoSelection, window, cx); + } + assert_add_selection_ranges( + editor, + &[ + Point::new(1, 2)..Point::new(1, 2), + Point::new(2, 2)..Point::new(2, 2), + ], + cx, + ); + add_selection_in_direction(editor, !above, skip_soft_wrap, window, cx); + assert_add_selection_ranges(editor, &[source..source], cx); + editor.undo_selection(&UndoSelection, window, cx); + editor.redo_selection(&RedoSelection, window, cx); + assert_add_selection_ranges(editor, &[source..source], cx); + }); + } + } + } + } +} + +#[gpui::test] +async fn test_add_selection_pending_overlap_commit_or_consume(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + for skip_soft_wrap in [false, true] { + for finish_pending in [false, true] { + for (above, overlap_row, columns) in [ + (false, 1, [(0, 0..3), (1, 0..3), (2, 2..2)]), + (false, 2, [(0, 2..2), (1, 0..3), (2, 0..3)]), + (true, 1, [(1, 0..3), (2, 0..3), (3, 2..2)]), + (true, 2, [(1, 2..2), (2, 0..3), (3, 0..3)]), + ] { + cx.set_state("abcdef\nabcdef\nabcdef\nabcdefˇ"); + cx.update_editor(|editor, window, cx| { + let source = Point::new(if above { 2 } else { 1 }, 2); + editor.change_selections( + SelectionEffects::no_scroll(), + window, + cx, + |selections| { + selections.select_ranges([source..source]); + }, + ); + add_selection_in_direction(editor, above, skip_soft_wrap, window, cx); + begin_add_selection_overlap(editor, overlap_row, 0..3, window, cx); + if finish_pending { + editor.end_selection(window, cx); + } + add_selection_in_direction(editor, !above, skip_soft_wrap, window, cx); + let expected = columns + .into_iter() + .map(|(row, columns)| { + Point::new(row, columns.start)..Point::new(row, columns.end) + }) + .collect::>(); + assert_add_selection_ranges(editor, &expected, cx); + assert_eq!(editor.selections.pending_anchor(), None); + }); + } + } + } +} + +#[gpui::test] +async fn test_add_selection_pending_overlap_multiline_goal(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + for skip_soft_wrap in [false, true] { + for (above, initial, _, _) in multiline_add_selection_history_states() { + for (cancel_pending, finish_pending) in [(true, false), (false, true), (false, false)] { + cx.set_state(initial); + cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, above, skip_soft_wrap, window, cx); + begin_add_selection_overlap( + editor, + if above { 2 } else { 1 }, + 0..2, + window, + cx, + ); + if cancel_pending { + editor.cancel(&Cancel, window, cx); + editor.undo_selection(&UndoSelection, window, cx); + editor.redo_selection(&RedoSelection, window, cx); + } else if finish_pending { + editor.end_selection(window, cx); + } + add_selection_in_direction(editor, above, skip_soft_wrap, window, cx); + let columns = if cancel_pending { + [1..3, 1..4, 1..4, 1..4] + } else { + [1..3, 0..4, 0..4, 1..if skip_soft_wrap { 3 } else { 4 }] + }; + let expected = (0..4) + .map(|row| { + let columns = &columns[if above { 3 - row } else { row }]; + Point::new(row as u32, columns.start) + ..Point::new(row as u32, columns.end) + }) + .collect::>(); + assert_add_selection_ranges(editor, &expected, cx); + }); + } + } + } +} + +#[gpui::test] +async fn test_add_selection_pending_overlap_committed_source_changes(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + for (above, initial, _, _) in multiline_add_selection_history_states() { + cx.set_state(initial); + cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, above, true, window, cx); + begin_add_selection_overlap(editor, if above { 2 } else { 1 }, 0..2, window, cx); + let snapshot = editor.display_snapshot(cx); + let source_row = if above { 3 } else { 0 }; + let selections = editor + .selections + .disjoint_anchors() + .iter() + .map(|selection| { + let mut selection = + selection.map(|anchor| anchor.to_point(snapshot.buffer_snapshot())); + if selection.start.row == source_row { + selection.end.column = 2; + } + selection + }) + .collect::>(); + let pending = *editor.selections.pending_anchor().expect("pending overlap"); + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |collection| { + collection.select(selections); + collection.set_pending(pending, SelectMode::Character); + }); + editor.cancel(&Cancel, window, cx); + add_selection_in_direction(editor, above, true, window, cx); + let columns = [1..2, 1..4, 1..4, 1..2]; + let expected = (0..4) + .map(|row| { + let columns = &columns[if above { 3 - row } else { row }]; + Point::new(row as u32, columns.start)..Point::new(row as u32, columns.end) + }) + .collect::>(); + assert_add_selection_ranges(editor, &expected, cx); + }); + } +} + +#[gpui::test] +async fn test_add_selection_pending_overlap_retreat_after_buffer_edit(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + for skip_soft_wrap in [false, true] { + for (above, initial, _, _) in multiline_add_selection_history_states() { + for (cancel_pending, finish_pending) in [(true, false), (false, true), (false, false)] { + cx.set_state(initial); + let overlap_row = if above { 2 } else { 1 }; + cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, above, skip_soft_wrap, window, cx); + begin_add_selection_overlap(editor, overlap_row, 0..2, window, cx); + }); + cx.update_buffer(|buffer, cx| { + let start = Point::new(overlap_row, 0); + buffer.edit([(start..start, "ZZ")], None, cx); + }); + cx.update_editor(|editor, window, cx| { + editor.update_selection( + DisplayPoint::new(DisplayRow(overlap_row), 0), + 0, + gpui::Point::::default(), + window, + cx, + ); + if cancel_pending { + editor.cancel(&Cancel, window, cx); + } else if finish_pending { + editor.end_selection(window, cx); + } + add_selection_in_direction(editor, above, skip_soft_wrap, window, cx); + let columns = [1..3, 3..6, 1..4, 1..if skip_soft_wrap { 6 } else { 4 }]; + let mut expected = (0..4) + .map(|row| { + let columns = &columns[if above { 3 - row } else { row }]; + Point::new(row as u32, columns.start) + ..Point::new(row as u32, columns.end) + }) + .collect::>(); + if !cancel_pending { + expected.extend((1..3).map(|row| Point::new(row, 0)..Point::new(row, 0))); + expected.sort_by_key(|range| range.start); + } + assert_add_selection_ranges(editor, &expected, cx); + }); + } + } + } +} + +#[gpui::test] +async fn test_add_selection_pending_overlap_absorbs_multiline_source(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + for skip_soft_wrap in [false, true] { + for (cancel_pending, finish_pending) in [(true, false), (false, true), (false, false)] { + cx.set_state("a«bc\ndefgˇ»hi\nuv\nabcdef"); + cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, false, skip_soft_wrap, window, cx); + editor.begin_selection(DisplayPoint::new(DisplayRow(0), 0), true, 1, window, cx); + editor.update_selection( + DisplayPoint::new(DisplayRow(1), 4), + 0, + gpui::Point::::default(), + window, + cx, + ); + if cancel_pending { + editor.cancel(&Cancel, window, cx); + } else if finish_pending { + editor.end_selection(window, cx); + } + add_selection_in_direction(editor, false, skip_soft_wrap, window, cx); + let columns = if cancel_pending { + [1..3, 1..4, 1..2, 1..4] + } else { + [0..3, 0..4, 0..2, 1..2] + }; + let expected = columns + .into_iter() + .enumerate() + .map(|(row, columns)| { + Point::new(row as u32, columns.start)..Point::new(row as u32, columns.end) + }) + .collect::>(); + assert_add_selection_ranges(editor, &expected, cx); + }); + } + } +} + +#[gpui::test] +async fn test_add_selection_goal_changes_are_group_local(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + for remote in [false, true] { + for (changed_row, columns, goal_end) in [ + (0, Some(1..2), 2), + (1, Some(1..2), 3), + (0, None, 4), + (1, None, 4), + (1, Some(1..4), 4), + ] { + cx.set_state( + "a«bc\ndefgˇ»hi\nuvwxyz\nabcdef\n\na«bc\ndefgˇ»hi\nuvwxyz\nabcdef\n012345", + ); + cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, false, true, window, cx); + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| { + selections.insert_range(Point::new(9, 0)..Point::new(9, 0)); + }); + let snapshot = editor.display_snapshot(cx); + let selections = editor + .selections + .all_unexpanded::(&snapshot) + .into_iter() + .filter_map(|mut selection| { + if selection.start.row == changed_row { + let columns = columns.as_ref()?; + selection.start.column = columns.start; + selection.end.column = columns.end; + } + Some(selection) + }) + .collect::>(); + if remote { + let selections = selections + .into_iter() + .map(|selection| { + selection.map(|point| snapshot.buffer_snapshot().anchor_before(point)) + }) + .collect(); + editor.set_selections_from_remote(selections, None, window, cx); + } else { + editor.change_selections( + SelectionEffects::no_scroll(), + window, + cx, + |collection| { + collection.select(selections); + }, + ); + } + add_selection_in_direction(editor, false, true, window, cx); + let expected = (0..10) + .filter_map(|row| { + let columns = if row == changed_row { + columns.clone()? + } else { + match row { + 0 | 5 => 1..3, + 1 | 2 | 6 | 7 | 8 => 1..4, + 3 => 1..goal_end, + 9 => 0..0, + _ => return None, + } + }; + Some(Point::new(row, columns.start)..Point::new(row, columns.end)) + }) + .collect::>(); + assert_add_selection_ranges(editor, &expected, cx); + }); + } + } +} + +#[gpui::test] +async fn test_add_selection_overlap_invalidates_goal(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + for (pending, finish_pending) in [(false, false), (true, false), (true, true)] { + for preceding_range in [false, true] { + for (columns, pending_goal, inserted_goal) in + [(0..1, 4, 4), (0..2, 3, 3), (4..4, 3, 4), (4..5, 4, 4)] + { + cx.set_state("a«bc\ndefgˇ»hi\nuvwxyz\nabcdef"); + cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, false, true, window, cx); + if preceding_range { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| { + selections.insert_range(Point::new(1, 0)..Point::new(1, 1)); + }); + } + let snapshot = editor.display_snapshot(cx); + let range = snapshot.buffer_snapshot().anchor_before(Point::new(1, columns.start)) + ..snapshot.buffer_snapshot().anchor_before(Point::new(1, columns.end)); + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| { + if pending { + selections.set_pending_anchor_range(range, SelectMode::Character); + } else { + selections.insert_range(range); + } + }); + if finish_pending { + editor.end_selection(window, cx); + } + add_selection_in_direction(editor, false, true, window, cx); + let goal_end = if pending { pending_goal } else { inserted_goal }; + assert_eq!( + editor.selections.ranges::(&editor.display_snapshot(cx)) + .into_iter() + .filter(|range| range.start.row == 3) + .collect::>(), + [Point::new(3, 1)..Point::new(3, goal_end)], + "pending={pending}, finish_pending={finish_pending}, preceding_range={preceding_range}, columns={columns:?}", + ); + }); + } + } + } +} + +#[gpui::test] +async fn test_add_selection_sync_preserves_unchanged_goals(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + for subscribe_before in [false, true] { + for move_source in [false, true] { + cx.set_state("a«bc\ndefgˇ»hi\nuvwxyz\nabcdef"); + cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, false, true, window, cx); + }); + let (_source, _subscription) = cx.update_editor(|editor, window, cx| { + let source = cx.new(|cx| editor.clone(window, cx)); + let subscription = + subscribe_before.then(|| editor.sync_selections(source.clone(), cx)); + source.update(cx, |source, cx| { + source.change_selections( + SelectionEffects::no_scroll(), + window, + cx, + |collection| { + let mut selections = collection.all_unexpanded::( + &collection.display_snapshot(), + ); + for selection in &mut selections { + if move_source && selection.start == MultiBufferOffset(1) { + selection.end = MultiBufferOffset(2); + } + } + collection.select(selections); + }, + ); + }); + let subscription = + subscription.unwrap_or_else(|| editor.sync_selections(source.clone(), cx)); + (source, subscription) + }); + cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, false, true, window, cx); + }); + cx.assert_editor_state(if move_source { + "a«bˇ»c\nd«efgˇ»hi\nu«vwxˇ»yz\na«bˇ»cdef" + } else { + "a«bcˇ»\nd«efgˇ»hi\nu«vwxˇ»yz\na«bcdˇ»ef" + }); + } + } +} + +#[gpui::test] +async fn test_add_selection_unwrapped_goal_reseeds_from_oldest_survivor(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + cx.set_state("a«bc\ndefgˇ»hi\nuv\nabcdef"); + cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, false, true, window, cx); + let oldest_id = editor.selections.oldest_anchor().id; + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| { + selections.delete(oldest_id); + }); + add_selection_in_direction(editor, false, true, window, cx); + }); + cx.assert_editor_state("abc\nd«efgˇ»hi\nu«vˇ»\na«bcdˇ»ef"); +} + +#[gpui::test] +async fn test_add_selection_unwrapped_multiline_goal_follows_buffer_edits(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + for add_pending in [false, true] { + cx.set_state("a«bc\ndefgˇ»hi\nuvwxyz\nabcdefgh"); + cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, false, true, window, cx); + }); + cx.update_buffer(|buffer, cx| { + buffer.edit([(Point::new(1, 0)..Point::new(1, 0), "ZZ")], None, cx); + }); + cx.update_editor(|editor, window, cx| { + if add_pending { + editor.begin_selection(DisplayPoint::new(DisplayRow(3), 0), true, 1, window, cx); + editor.end_selection(window, cx); + } + add_selection_in_direction(editor, false, true, window, cx); + }); + let caret = if add_pending { "ˇ" } else { "" }; + cx.assert_editor_state(&format!( + "a«bcˇ»\nZZd«efgˇ»hi\nu«vwxˇ»yz\n{caret}a«bcdefˇ»gh" + )); + } +} + +#[gpui::test] +async fn test_add_selection_unwrapped_multiline_goal_follows_tab_size(cx: &mut TestAppContext) { + init_test(cx, |settings| { + settings.defaults.tab_size = NonZeroU32::new(4); + }); + let mut cx = EditorTestContext::new(cx).await; + cx.set_state("a«bc\n\tˇ»defgh\nabcdefghij\nabcdefghij"); + cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, false, true, window, cx); + }); + cx.assert_editor_state("a«bcˇ»\n«\tˇ»defgh\na«bcdˇ»efghij\nabcdefghij"); + for (tab_size, expected) in [ + (8, "a«bcˇ»\n«\tˇ»defgh\na«bcdˇ»efghij\na«bcdefghˇ»ij"), + (4, "a«bcˇ»\n«\tˇ»defgh\na«bcdˇ»efghij\na«bcdˇ»efghij"), + ] { + update_test_language_settings(&mut cx, &|settings| { + settings.defaults.tab_size = NonZeroU32::new(tab_size); + }); + cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, false, true, window, cx); + }); + cx.assert_editor_state(expected); + cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, true, true, window, cx); + }); + } +} + +#[gpui::test] +async fn test_add_selection_unwrapped_multiline_goal_follows_inlays(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + cx.set_state("a«bc\ndefgˇ»hi\nabcdefghij\nabcdefghij"); + cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, false, true, window, cx); + let position = editor + .buffer + .read(cx) + .snapshot(cx) + .anchor_before(Point::new(1, 0)); + editor.splice_inlays(&[], vec![inlays::Inlay::mock_hint(0, position, "HH")], cx); + add_selection_in_direction(editor, false, true, window, cx); + }); + cx.assert_editor_state("a«bcˇ»\nd«efgˇ»hi\na«bcdˇ»efghij\na«bcdefˇ»ghij"); + cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, true, true, window, cx); + editor.splice_inlays(&[project::InlayId::Hint(0)], Vec::new(), cx); + add_selection_in_direction(editor, false, true, window, cx); + }); + cx.assert_editor_state("a«bcˇ»\nd«efgˇ»hi\na«bcdˇ»efghij\na«bcdˇ»efghij"); +} + +#[gpui::test] +async fn test_add_selection_unwrapped_clears_pixel_goals(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + cx.set_state("abˇcdef\nabcdef"); + cx.update_editor(|editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| { + selections.move_with(&mut |_, selection| { + selection.goal = SelectionGoal::HorizontalRange { + start: 0.0, + end: 200.0, + }; + }); + }); + add_selection_in_direction(editor, false, true, window, cx); + assert_eq!( + editor + .selections + .all::(&editor.display_snapshot(cx)) + .iter() + .map(|selection| selection.goal) + .collect::>(), + [SelectionGoal::None, SelectionGoal::None], + ); + }); + cx.assert_editor_state("abˇcdef\nabˇcdef"); +} + +#[gpui::test] +async fn test_add_selection_skips_inlay_inside_grapheme(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + for (prefix, row) in [ + (String::new(), 0), + (format!("{}\n\n\n", "a".repeat(121)), 3), + ] { + cx.set_state(&format!("{prefix}👩\u{200d}💻|Z\nˇ")); + cx.update_editor(|editor, window, cx| { + let buffer = editor.buffer.read(cx).snapshot(cx); + let position = buffer.anchor_before(Point::new(row, 4)); + editor.splice_inlays(&[], vec![inlays::Inlay::mock_hint(0, position, "\n")], cx); + add_selection_in_direction(editor, true, true, window, cx); + assert_eq!( + editor + .selections + .ranges::(&editor.display_snapshot(cx)), + [ + Point::new(row, 0)..Point::new(row, 0), + Point::new(row + 1, 0)..Point::new(row + 1, 0) + ] + ); + }); + } +} + +#[gpui::test] +async fn test_add_selection_unwrapped_en_dash(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + + for columns in [[4..4, 2..2], [3..4, 1..2], [4..6, 2..4]] { + for above in [false, true] { + for reversed in [false, true] { + cx.set_state("–x|Z\nab|Zˇ"); + cx.update_editor(|editor, window, cx| { + assert_add_selection_pair(editor, columns.clone(), above, reversed, window, cx); + }); + } + } + } +} + +#[gpui::test] +async fn test_add_selection_unwrapped_unicode_tabs(cx: &mut TestAppContext) { + init_test(cx, |settings| { + settings.defaults.hard_tabs = Some(true); + settings.defaults.tab_size = Some(4.try_into().expect("nonzero tab size")); + }); + let mut cx = EditorTestContext::new(cx).await; + + for columns in [[8..8, 4..4], [6..8, 3..4], [8..10, 4..6]] { + for above in [false, true] { + for reversed in [false, true] { + cx.set_state("–é\tλ|Z\nab\tc|Zˇ"); + cx.update_editor(|editor, window, cx| { + assert_add_selection_pair(editor, columns.clone(), above, reversed, window, cx); + }); + } + } + } +} + +#[gpui::test] +async fn test_add_selection_unwrapped_source_range_across_wraps(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + + for wrap_width in [None, Some(px(60.0)), Some(px(90.0))] { + for columns in [3..18, 3..20, 18..18] { + for above in [false, true] { + for reversed in [false, true] { + cx.set_state(" abcdefghijklmnop|Z\n ab cd efghijklmnop|Zˇ"); + cx.update_editor(|editor, window, cx| { + editor.set_wrap_width(wrap_width, cx); + if wrap_width.is_some() { + let snapshot = editor.display_snapshot(cx); + for row in 0..2 { + assert_ne!( + Point::new(row, 3).to_display_point(&snapshot).row(), + Point::new(row, 18).to_display_point(&snapshot).row(), + ); + } + } + assert_add_selection_pair( + editor, + [columns.clone(), columns.clone()], + above, + reversed, + window, + cx, + ); + }); + } + } + } + } +} + +#[gpui::test] +async fn test_add_selection_unwrapped_clamp_retains_goal(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + + for (before, intermediate, after, above) in [ + ( + "–abcˇ|Z\nx\nwxyz|Z", + "–abcˇ|Z\nxˇ\nwxyz|Z", + "–abcˇ|Z\nxˇ\nwxyzˇ|Z", + false, + ), + ( + "wxyz|Z\nx\n–abcˇ|Z", + "wxyz|Z\nxˇ\n–abcˇ|Z", + "wxyzˇ|Z\nxˇ\n–abcˇ|Z", + true, + ), + ( + "–a«ˇbc|Z»\nxyz\nuvwxyz", + "–a«ˇbc|Z»\nxy«ˇz»\nuvwxyz", + "–a«ˇbc|Z»\nxy«ˇz»\nuv«ˇwxyz»", + false, + ), + ( + "uvwxyz\nxyz\n–a«bc|Zˇ»", + "uvwxyz\nxy«zˇ»\n–a«bc|Zˇ»", + "uv«wxyzˇ»\nxy«zˇ»\n–a«bc|Zˇ»", + true, + ), + ] { + cx.set_state(before); + cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, above, true, window, cx); + }); + cx.assert_editor_state(intermediate); + cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, above, true, window, cx); + }); + cx.assert_editor_state(after); + } +} + +#[gpui::test] +async fn test_add_selection_unwrapped_multiple_groups(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + cx.set_state("–aˇbcdef\nx\nabcdef\nabc«ˇde»f\nxy\nabcdef"); + + cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, false, true, window, cx); + }); + cx.assert_editor_state("–aˇbcdef\nxˇ\nabcdef\nabc«ˇde»f\nxy\nabc«ˇde»f"); + + cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, false, true, window, cx); + }); + cx.assert_editor_state("–aˇbcdef\nxˇ\nabˇcdef\nabc«ˇde»f\nxy\nabc«ˇde»f"); + + cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, true, true, window, cx); + }); + cx.assert_editor_state("–aˇbcdef\nxˇ\nabcdef\nabc«ˇde»f\nxy\nabcdef"); + + cx.update_editor(|editor, window, cx| { + add_selection_in_direction(editor, true, true, window, cx); + }); + cx.assert_editor_state("–aˇbcdef\nx\nabc«ˇde»f\nabc«ˇde»f\nxy\nabcdef"); +} + +#[gpui::test] +async fn test_add_selection_unwrapped_mode_switch_resets_groups(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + + for skip_soft_wrap in [false, true] { + cx.set_state("abˇcdefghijklmnop\nqrstuvwxyzABCDEF\n0123456789abcdef"); + cx.update_editor(|editor, window, cx| { + editor.set_wrap_width(None, cx); + let details = editor.text_layout_details(window, cx); + let width = editor + .display_snapshot(cx) + .layout_row(DisplayRow(0), &details) + .width + / 2.0; + editor.set_wrap_width(Some(width), cx); + assert_eq!( + editor.display_text(cx), + "abcdefgh\nijklmnop\nqrstuvwx\nyzABCDEF\n01234567\n89abcdef", + ); + add_selection_in_direction(editor, false, skip_soft_wrap, window, cx); + add_selection_in_direction(editor, skip_soft_wrap, !skip_soft_wrap, window, cx); + }); + if skip_soft_wrap { + cx.assert_editor_state("abˇcdefghijˇklmnop\nqrˇstuvwxyzABCDEF\n0123456789abcdef"); + } else { + cx.assert_editor_state("abˇcdefghijˇklmnop\nqrˇstuvwxyzˇABCDEF\n0123456789abcdef"); + } + } +} + +#[gpui::test] +async fn test_add_selection_unwrapped_eof_and_skipped_range_terminate(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + + for state in [ + "abcdefghijklmnopˇ", + "abcdefghijklmnop\nqrstuvwxyzABCDEFˇ", + "abcdefghijkl«mnopˇ»\nabcdefgh", + ] { + cx.set_state(state); + cx.update_editor(|editor, window, cx| { + editor.set_wrap_width(None, cx); + let details = editor.text_layout_details(window, cx); + let width = editor + .display_snapshot(cx) + .layout_row(DisplayRow(0), &details) + .width + / 4.0; + editor.set_wrap_width(Some(width), cx); + assert!(editor.display_snapshot(cx).max_point().row().0 >= 3); + for _ in 0..2 { + add_selection_in_direction(editor, false, true, window, cx); + } + }); + cx.assert_editor_state(state); + } +} + +#[gpui::test] +async fn test_add_selection_unwrapped_inlay_rows_and_widths(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + + for columns in [[1..1, 3..3], [1..3, 3..5]] { + for above in [false, true] { + cx.set_state("a|Z\nabc|Zˇ"); + cx.update_editor(|editor, window, cx| { + let position = editor + .buffer + .read(cx) + .snapshot(cx) + .anchor_before(Point::new(0, 0)); + editor.splice_inlays( + &[], + vec![inlays::Inlay::mock_hint(0, position, "hint\n–é")], + cx, + ); + assert_eq!(editor.display_text(cx), "hint\n–éa|Z\nabc|Z"); + assert_add_selection_pair(editor, columns.clone(), above, false, window, cx); + editor.splice_inlays(&[project::InlayId::Hint(0)], Vec::new(), cx); + }); + } + } +} + +#[gpui::test] +async fn test_add_selection_unwrapped_folded_target_boundaries(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + + for (source, target) in [(4..4, 2..2), (5..5, 8..8), (4..5, 2..8)] { + for above in [false, true] { + for reversed in [false, true] { + cx.set_state(if above { + "abhidden|Z\na–|Zˇ" + } else { + "a–|Z\nabhidden|Zˇ" + }); + cx.update_editor(|editor, window, cx| { + let target_row = u32::from(!above); + editor.fold_creases( + vec![Crease::simple( + Point::new(target_row, 2)..Point::new(target_row, 8), + FoldPlaceholder::test(), + )], + true, + window, + cx, + ); + assert_eq!( + editor.display_text(cx), + if above { + "ab⋯|Z\na–|Z" + } else { + "a–|Z\nab⋯|Z" + } + ); + let columns = if above { + [target.clone(), source.clone()] + } else { + [source.clone(), target.clone()] + }; + assert_add_selection_pair(editor, columns, above, reversed, window, cx); + }); + } + } + } +} + +#[gpui::test] +async fn test_add_selection_unwrapped_multiline_fold_rows(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + + for above in [false, true] { + cx.set_state(if above { + "abhidden\nhidden\n|Z\na–ˇ|Z" + } else { + "a–ˇ|Z\nabhidden\nhidden\n|Z" + }); + cx.update_editor(|editor, window, cx| { + let target_row = u32::from(!above); + editor.fold_creases( + vec![Crease::simple( + Point::new(target_row, 2)..Point::new(target_row + 2, 0), + FoldPlaceholder::test(), + )], + true, + window, + cx, + ); + assert_eq!( + editor.display_text(cx), + if above { + "ab⋯|Z\na–|Z" + } else { + "a–|Z\nab⋯|Z" + } + ); + add_selection_in_direction(editor, above, true, window, cx); + }); + cx.assert_editor_state(if above { + "abˇhidden\nhidden\n|Z\na–ˇ|Z" + } else { + "a–ˇ|Z\nabˇhidden\nhidden\n|Z" + }); + } +} + +/// A different number of tabs can align the same column on each row, so a cursor has to +/// be placed by the column the tabs expand to. Counting a tab as a single column lands it +/// wherever that many characters happen to reach on the next row. +#[gpui::test] +async fn test_add_selection_below_with_tab_aligned_columns(cx: &mut TestAppContext) { + init_test(cx, |settings| { + settings.defaults.hard_tabs = Some(true); + settings.defaults.tab_size = Some(4.try_into().unwrap()); + }); + + let mut cx = EditorTestContext::new(cx).await; + + // Both `=` render at column 48: `current.rgb.r` ends at 37 and is padded by three + // tabs, `residuals[run].rgb.r` ends at 44 and is padded by one. + cx.set_state( + "\t\t\t\t\t\tcurrent.rgb.r\t\t\tˇ= p[0] >> 8;\n\t\t\t\t\t\tresiduals[run].rgb.r\t= p[0] & 0xff;\n", + ); + + cx.update_editor(|editor, window, cx| { + editor.add_selection_below( + &AddSelectionBelow { + skip_soft_wrap: true, + }, + window, + cx, + ); + }); + + cx.assert_editor_state( + "\t\t\t\t\t\tcurrent.rgb.r\t\t\tˇ= p[0] >> 8;\n\t\t\t\t\t\tresiduals[run].rgb.r\tˇ= p[0] & 0xff;\n", + ); +} + +#[gpui::test] +/// Regression test for a panic ("display point out of range"): with a multi-line fold, +/// buffer rows below the fold exceed the fold map's max row, so they must be converted +/// to tab map rows instead of being used directly. +async fn test_add_selection_above_below_with_fold(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + cx.set_state(indoc!( + r#"fn foo() { + aaaa + bbbb + } + one + twoˇ"# + )); + + cx.update_editor(|editor, window, cx| { + editor.fold_creases( + vec![Crease::simple( + Point::new(0, 10)..Point::new(3, 0), + FoldPlaceholder::test(), + )], + true, + window, + cx, + ); + }); + + cx.update_editor(|editor, window, cx| { + editor.add_selection_above( + &AddSelectionAbove { + skip_soft_wrap: true, + }, + window, + cx, + ); + }); + + cx.assert_editor_state(indoc!( + r#"fn foo() { + aaaa + bbbb + } + oneˇ + twoˇ"# + )); + + cx.update_editor(|editor, window, cx| { + editor.add_selection_above( + &AddSelectionAbove { + skip_soft_wrap: true, + }, + window, + cx, + ); + }); + + cx.assert_editor_state(indoc!( + r#"fn ˇfoo() { + aaaa + bbbb + } + oneˇ + twoˇ"# + )); + + cx.set_state(indoc!( + r#"fn fˇoo() { + aaaa + bbbb + } + one + two"# + )); + + cx.update_editor(|editor, window, cx| { + editor.fold_creases( + vec![Crease::simple( + Point::new(0, 10)..Point::new(3, 0), + FoldPlaceholder::test(), + )], + true, + window, + cx, + ); + }); + + cx.update_editor(|editor, window, cx| { + editor.add_selection_below( + &AddSelectionBelow { + skip_soft_wrap: true, + }, + window, + cx, + ); + }); + + cx.assert_editor_state(indoc!( + r#"fn fˇoo() { + aaaa + bbbb + } + oneˇ + two"# + )); +} + +#[gpui::test] +async fn test_add_selection_above_below(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + cx.set_state(indoc!( + r#"abc + defˇghi + + jk + nlmo + "# + )); + + cx.update_editor(|editor, window, cx| { + editor.add_selection_above(&Default::default(), window, cx); + }); + + cx.assert_editor_state(indoc!( + r#"abcˇ + defˇghi + + jk + nlmo + "# + )); + + cx.update_editor(|editor, window, cx| { + editor.add_selection_above(&Default::default(), window, cx); + }); + + cx.assert_editor_state(indoc!( + r#"abcˇ + defˇghi + + jk + nlmo + "# + )); + + cx.update_editor(|editor, window, cx| { + editor.add_selection_below(&Default::default(), window, cx); + }); + + cx.assert_editor_state(indoc!( + r#"abc + defˇghi + + jk + nlmo + "# + )); + + cx.update_editor(|editor, window, cx| { + editor.undo_selection(&Default::default(), window, cx); + }); + + cx.assert_editor_state(indoc!( + r#"abcˇ + defˇghi + + jk + nlmo + "# + )); + + cx.update_editor(|editor, window, cx| { + editor.redo_selection(&Default::default(), window, cx); + }); + + cx.assert_editor_state(indoc!( + r#"abc + defˇghi + + jk + nlmo + "# + )); + + cx.update_editor(|editor, window, cx| { + editor.add_selection_below(&Default::default(), window, cx); + }); + + cx.assert_editor_state(indoc!( + r#"abc + defˇghi + ˇ + jk + nlmo + "# + )); + + cx.update_editor(|editor, window, cx| { + editor.add_selection_below(&Default::default(), window, cx); + }); + + cx.assert_editor_state(indoc!( + r#"abc + defˇghi + ˇ + jkˇ + nlmo + "# + )); + + cx.update_editor(|editor, window, cx| { + editor.add_selection_below(&Default::default(), window, cx); + }); + + cx.assert_editor_state(indoc!( + r#"abc + defˇghi + ˇ + jkˇ + nlmˇo + "# + )); + + cx.update_editor(|editor, window, cx| { + editor.add_selection_below(&Default::default(), window, cx); + }); + + cx.assert_editor_state(indoc!( + r#"abc + defˇghi + ˇ + jkˇ + nlmˇo + ˇ"# + )); + + // change selections + cx.set_state(indoc!( + r#"abc + def«ˇg»hi + + jk + nlmo + "# + )); + + cx.update_editor(|editor, window, cx| { + editor.add_selection_below(&Default::default(), window, cx); + }); + + cx.assert_editor_state(indoc!( + r#"abc + def«ˇg»hi + + jk + nlm«ˇo» + "# + )); + + cx.update_editor(|editor, window, cx| { + editor.add_selection_below(&Default::default(), window, cx); + }); + + cx.assert_editor_state(indoc!( + r#"abc + def«ˇg»hi + + jk + nlm«ˇo» + "# + )); + + cx.update_editor(|editor, window, cx| { + editor.add_selection_above(&Default::default(), window, cx); + }); + + cx.assert_editor_state(indoc!( + r#"abc + def«ˇg»hi + + jk + nlmo + "# + )); + + cx.update_editor(|editor, window, cx| { + editor.add_selection_above(&Default::default(), window, cx); + }); + + cx.assert_editor_state(indoc!( + r#"abc + def«ˇg»hi + + jk + nlmo + "# + )); + + // Change selections again + cx.set_state(indoc!( + r#"a«bc + defgˇ»hi + + jk + nlmo + "# + )); + + cx.update_editor(|editor, window, cx| { + editor.add_selection_below(&Default::default(), window, cx); + }); + + cx.assert_editor_state(indoc!( + r#"a«bcˇ» + d«efgˇ»hi + + j«kˇ» + nlmo + "# + )); + + cx.update_editor(|editor, window, cx| { + editor.add_selection_below(&Default::default(), window, cx); + }); + cx.assert_editor_state(indoc!( + r#"a«bcˇ» + d«efgˇ»hi + + j«kˇ» + n«lmoˇ» + "# + )); + cx.update_editor(|editor, window, cx| { + editor.add_selection_above(&Default::default(), window, cx); + }); + + cx.assert_editor_state(indoc!( + r#"a«bcˇ» + d«efgˇ»hi + + j«kˇ» + nlmo + "# + )); + + // Change selections again + cx.set_state(indoc!( + r#"abc + d«ˇefghi + + jk + nlm»o + "# + )); + + cx.update_editor(|editor, window, cx| { + editor.add_selection_above(&Default::default(), window, cx); + }); + + cx.assert_editor_state(indoc!( + r#"a«ˇbc» + d«ˇef»ghi + + j«ˇk» + n«ˇlm»o + "# + )); + + cx.update_editor(|editor, window, cx| { + editor.add_selection_below(&Default::default(), window, cx); + }); + + cx.assert_editor_state(indoc!( + r#"abc + d«ˇef»ghi + + j«ˇk» + n«ˇlm»o + "# + )); + + // Assert that the oldest selection's goal column is used when adding more + // selections, not the most recently added selection's actual column. + cx.set_state(indoc! {" + foo bar bazˇ + foo + foo bar + "}); + + cx.update_editor(|editor, window, cx| { + editor.add_selection_below( + &AddSelectionBelow { + skip_soft_wrap: true, + }, + window, + cx, + ); + }); + + cx.assert_editor_state(indoc! {" + foo bar bazˇ + fooˇ + foo bar + "}); + + cx.update_editor(|editor, window, cx| { + editor.add_selection_below( + &AddSelectionBelow { + skip_soft_wrap: true, + }, + window, + cx, + ); + }); + + cx.assert_editor_state(indoc! {" + foo bar bazˇ + fooˇ + foo barˇ + "}); + + cx.set_state(indoc! {" + foo bar baz + foo + foo barˇ + "}); + + cx.update_editor(|editor, window, cx| { + editor.add_selection_above( + &AddSelectionAbove { + skip_soft_wrap: true, + }, + window, + cx, + ); + }); + + cx.assert_editor_state(indoc! {" + foo bar baz + fooˇ + foo barˇ + "}); + + cx.update_editor(|editor, window, cx| { + editor.add_selection_above( + &AddSelectionAbove { + skip_soft_wrap: true, + }, + window, + cx, + ); + }); + + cx.assert_editor_state(indoc! {" + foo barˇ baz + fooˇ + foo barˇ + "}); +} + +#[gpui::test] +async fn test_add_selection_above_below_multi_cursor(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + + cx.set_state(indoc!( + r#"line onˇe + liˇne two + line three + line four"# + )); + + cx.update_editor(|editor, window, cx| { + editor.add_selection_below(&Default::default(), window, cx); + }); + + // test multiple cursors expand in the same direction + cx.assert_editor_state(indoc!( + r#"line onˇe + liˇne twˇo + liˇne three + line four"# + )); + + cx.update_editor(|editor, window, cx| { + editor.add_selection_below(&Default::default(), window, cx); + }); + + cx.update_editor(|editor, window, cx| { + editor.add_selection_below(&Default::default(), window, cx); + }); + + // test multiple cursors expand below overflow + cx.assert_editor_state(indoc!( + r#"line onˇe + liˇne twˇo + liˇne thˇree + liˇne foˇur"# + )); + + cx.update_editor(|editor, window, cx| { + editor.add_selection_above(&Default::default(), window, cx); + }); + + // test multiple cursors retrieves back correctly + cx.assert_editor_state(indoc!( + r#"line onˇe + liˇne twˇo + liˇne thˇree + line four"# + )); + + cx.update_editor(|editor, window, cx| { + editor.add_selection_above(&Default::default(), window, cx); + }); + + cx.update_editor(|editor, window, cx| { + editor.add_selection_above(&Default::default(), window, cx); + }); + + // test multiple cursor groups maintain independent direction - first expands up, second shrinks above + cx.assert_editor_state(indoc!( + r#"liˇne onˇe + liˇne two + line three + line four"# + )); + + cx.update_editor(|editor, window, cx| { + editor.undo_selection(&Default::default(), window, cx); + }); + + // test undo + cx.assert_editor_state(indoc!( + r#"line onˇe + liˇne twˇo + line three + line four"# + )); + + cx.update_editor(|editor, window, cx| { + editor.redo_selection(&Default::default(), window, cx); + }); + + // test redo + cx.assert_editor_state(indoc!( + r#"liˇne onˇe + liˇne two + line three + line four"# + )); + + cx.set_state(indoc!( + r#"abcd + ef«ghˇ» + ijkl + «mˇ»nop"# + )); + + cx.update_editor(|editor, window, cx| { + editor.add_selection_above(&Default::default(), window, cx); + }); + + // test multiple selections expand in the same direction + cx.assert_editor_state(indoc!( + r#"ab«cdˇ» + ef«ghˇ» + «iˇ»jkl + «mˇ»nop"# + )); + + cx.update_editor(|editor, window, cx| { + editor.add_selection_above(&Default::default(), window, cx); + }); + + // test multiple selection upward overflow + cx.assert_editor_state(indoc!( + r#"ab«cdˇ» + «eˇ»f«ghˇ» + «iˇ»jkl + «mˇ»nop"# + )); + + cx.update_editor(|editor, window, cx| { + editor.add_selection_below(&Default::default(), window, cx); + }); + + // test multiple selection retrieves back correctly + cx.assert_editor_state(indoc!( + r#"abcd + ef«ghˇ» + «iˇ»jkl + «mˇ»nop"# + )); + + cx.update_editor(|editor, window, cx| { + editor.add_selection_below(&Default::default(), window, cx); + }); + + // test multiple cursor groups maintain independent direction - first shrinks down, second expands below + cx.assert_editor_state(indoc!( + r#"abcd + ef«ghˇ» + ij«klˇ» + «mˇ»nop"# + )); + + cx.update_editor(|editor, window, cx| { + editor.undo_selection(&Default::default(), window, cx); + }); + + // test undo + cx.assert_editor_state(indoc!( + r#"abcd + ef«ghˇ» + «iˇ»jkl + «mˇ»nop"# + )); + + cx.update_editor(|editor, window, cx| { + editor.redo_selection(&Default::default(), window, cx); + }); + + // test redo + cx.assert_editor_state(indoc!( + r#"abcd + ef«ghˇ» + ij«klˇ» + «mˇ»nop"# + )); +} + +#[gpui::test] +async fn test_add_selection_above_below_multi_cursor_existing_state(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + + cx.set_state(indoc!( + r#"line onˇe + liˇne two + line three + line four"# + )); + + cx.update_editor(|editor, window, cx| { + editor.add_selection_below(&Default::default(), window, cx); + editor.add_selection_below(&Default::default(), window, cx); + editor.add_selection_below(&Default::default(), window, cx); + }); + + // initial state with two multi cursor groups + cx.assert_editor_state(indoc!( + r#"line onˇe + liˇne twˇo + liˇne thˇree + liˇne foˇur"# + )); + + // add single cursor in middle - simulate opt click + cx.update_editor(|editor, window, cx| { + let new_cursor_point = DisplayPoint::new(DisplayRow(2), 4); + editor.begin_selection(new_cursor_point, true, 1, window, cx); + editor.end_selection(window, cx); + }); + + cx.assert_editor_state(indoc!( + r#"line onˇe + liˇne twˇo + liˇneˇ thˇree + liˇne foˇur"# + )); + + cx.update_editor(|editor, window, cx| { + editor.add_selection_above(&Default::default(), window, cx); + }); + + // test new added selection expands above and existing selection shrinks + cx.assert_editor_state(indoc!( + r#"line onˇe + liˇneˇ twˇo + liˇneˇ thˇree + line four"# + )); + + cx.update_editor(|editor, window, cx| { + editor.add_selection_above(&Default::default(), window, cx); + }); + + // test new added selection expands above and existing selection shrinks + cx.assert_editor_state(indoc!( + r#"lineˇ onˇe + liˇneˇ twˇo + lineˇ three + line four"# + )); + + // intial state with two selection groups + cx.set_state(indoc!( + r#"abcd + ef«ghˇ» + ijkl + «mˇ»nop"# + )); + + cx.update_editor(|editor, window, cx| { + editor.add_selection_above(&Default::default(), window, cx); + editor.add_selection_above(&Default::default(), window, cx); + }); + + cx.assert_editor_state(indoc!( + r#"ab«cdˇ» + «eˇ»f«ghˇ» + «iˇ»jkl + «mˇ»nop"# + )); + + // add single selection in middle - simulate opt drag + cx.update_editor(|editor, window, cx| { + let new_cursor_point = DisplayPoint::new(DisplayRow(2), 3); + editor.begin_selection(new_cursor_point, true, 1, window, cx); + editor.update_selection( + DisplayPoint::new(DisplayRow(2), 4), + 0, + gpui::Point::::default(), + window, + cx, + ); + editor.end_selection(window, cx); + }); + + cx.assert_editor_state(indoc!( + r#"ab«cdˇ» + «eˇ»f«ghˇ» + «iˇ»jk«lˇ» + «mˇ»nop"# + )); + + cx.update_editor(|editor, window, cx| { + editor.add_selection_below(&Default::default(), window, cx); + }); + + // test new added selection expands below, others shrinks from above + cx.assert_editor_state(indoc!( + r#"abcd + ef«ghˇ» + «iˇ»jk«lˇ» + «mˇ»no«pˇ»"# + )); +} + +#[gpui::test] +async fn test_add_selection_above_below_multibyte(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + + // Cursor after "Häl" (byte column 4, char column 3) should align to + // char column 3 on the ASCII line below, not byte column 4. + cx.set_state(indoc!( + r#"Hälˇlö + Hallo"# + )); + + cx.update_editor(|editor, window, cx| { + editor.add_selection_below(&Default::default(), window, cx); + }); + + cx.assert_editor_state(indoc!( + r#"Hälˇlö + Halˇlo"# + )); +} + +#[gpui::test] +async fn test_select_next(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + + // Enable case sensitive search. + update_test_editor_settings(&mut cx, &|settings| { + let mut search_settings = SearchSettingsContent::default(); + search_settings.case_sensitive = Some(true); + settings.search = Some(search_settings); + }); + + cx.set_state("abc\nˇabc abc\ndefabc\nabc"); + + cx.update_editor(|e, window, cx| e.select_next(&SelectNext::default(), window, cx)) + .unwrap(); + cx.assert_editor_state("abc\n«abcˇ» abc\ndefabc\nabc"); + + cx.update_editor(|e, window, cx| e.select_next(&SelectNext::default(), window, cx)) + .unwrap(); + cx.assert_editor_state("abc\n«abcˇ» «abcˇ»\ndefabc\nabc"); + + cx.update_editor(|editor, window, cx| editor.undo_selection(&UndoSelection, window, cx)); + cx.assert_editor_state("abc\n«abcˇ» abc\ndefabc\nabc"); + + cx.update_editor(|editor, window, cx| editor.redo_selection(&RedoSelection, window, cx)); + cx.assert_editor_state("abc\n«abcˇ» «abcˇ»\ndefabc\nabc"); + + cx.update_editor(|e, window, cx| e.select_next(&SelectNext::default(), window, cx)) + .unwrap(); + cx.assert_editor_state("abc\n«abcˇ» «abcˇ»\ndefabc\n«abcˇ»"); + + cx.update_editor(|e, window, cx| e.select_next(&SelectNext::default(), window, cx)) + .unwrap(); + cx.assert_editor_state("«abcˇ»\n«abcˇ» «abcˇ»\ndefabc\n«abcˇ»"); + + // Test selection direction should be preserved + cx.set_state("abc\n«ˇabc» abc\ndefabc\nabc"); + + cx.update_editor(|e, window, cx| e.select_next(&SelectNext::default(), window, cx)) + .unwrap(); + cx.assert_editor_state("abc\n«ˇabc» «ˇabc»\ndefabc\nabc"); + + // Test case sensitivity + cx.set_state("«ˇfoo»\nFOO\nFoo\nfoo"); + cx.update_editor(|e, window, cx| { + e.select_next(&SelectNext::default(), window, cx).unwrap(); + }); + cx.assert_editor_state("«ˇfoo»\nFOO\nFoo\n«ˇfoo»"); + + // Disable case sensitive search. + update_test_editor_settings(&mut cx, &|settings| { + let mut search_settings = SearchSettingsContent::default(); + search_settings.case_sensitive = Some(false); + settings.search = Some(search_settings); + }); + + cx.set_state("«ˇfoo»\nFOO\nFoo"); + cx.update_editor(|e, window, cx| { + e.select_next(&SelectNext::default(), window, cx).unwrap(); + e.select_next(&SelectNext::default(), window, cx).unwrap(); + }); + cx.assert_editor_state("«ˇfoo»\n«ˇFOO»\n«ˇFoo»"); + + // Enable whole word + update_test_editor_settings(&mut cx, &|settings| { + let mut search_settings = SearchSettingsContent::default(); + search_settings.whole_word = Some(true); + settings.search = Some(search_settings); + }); + + cx.set_state("abc\nabc «abcˇ»\ndefabc\nabc"); + cx.update_editor(|e, window, cx| e.select_next(&SelectNext::default(), window, cx)) + .unwrap(); + cx.assert_editor_state("abc\nabc «abcˇ»\ndefabc\n«abcˇ»"); +} + +#[gpui::test] +async fn test_select_all_matches(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + + // Enable case sensitive search. + update_test_editor_settings(&mut cx, &|settings| { + let mut search_settings = SearchSettingsContent::default(); + search_settings.case_sensitive = Some(true); + settings.search = Some(search_settings); + }); + + // Test caret-only selections + cx.set_state("abc\nˇabc abc\ndefabc\nabc"); + cx.update_editor(|e, window, cx| e.select_all_matches(&SelectAllMatches, window, cx)) + .unwrap(); + cx.assert_editor_state("«abcˇ»\n«abcˇ» «abcˇ»\ndefabc\n«abcˇ»"); + + // Test left-to-right selections + cx.set_state("abc\n«abcˇ»\nabc"); + cx.update_editor(|e, window, cx| e.select_all_matches(&SelectAllMatches, window, cx)) + .unwrap(); + cx.assert_editor_state("«abcˇ»\n«abcˇ»\n«abcˇ»"); + + // Test right-to-left selections + cx.set_state("abc\n«ˇabc»\nabc"); + cx.update_editor(|e, window, cx| e.select_all_matches(&SelectAllMatches, window, cx)) + .unwrap(); + cx.assert_editor_state("«ˇabc»\n«ˇabc»\n«ˇabc»"); + + // Test selecting whitespace with caret selection + cx.set_state("abc\nˇ abc\nabc"); + cx.update_editor(|e, window, cx| e.select_all_matches(&SelectAllMatches, window, cx)) + .unwrap(); + cx.assert_editor_state("abc\n« ˇ»abc\nabc"); + + // Test selecting whitespace with left-to-right selection + cx.set_state("abc\n«ˇ »abc\nabc"); + cx.update_editor(|e, window, cx| e.select_all_matches(&SelectAllMatches, window, cx)) + .unwrap(); + cx.assert_editor_state("abc\n«ˇ »abc\nabc"); + + // Test no matches with right-to-left selection + cx.set_state("abc\n« ˇ»abc\nabc"); + cx.update_editor(|e, window, cx| e.select_all_matches(&SelectAllMatches, window, cx)) + .unwrap(); + cx.assert_editor_state("abc\n« ˇ»abc\nabc"); + + // Test with a single word and clip_at_line_ends=true (#29823) + cx.set_state("aˇbc"); + cx.update_editor(|e, window, cx| { + e.set_clip_at_line_ends(true, cx); + e.select_all_matches(&SelectAllMatches, window, cx).unwrap(); + e.set_clip_at_line_ends(false, cx); + }); + cx.assert_editor_state("«abcˇ»"); + + // Test case sensitivity + cx.set_state("fˇoo\nFOO\nFoo"); + cx.update_editor(|e, window, cx| { + e.select_all_matches(&SelectAllMatches, window, cx).unwrap(); + }); + cx.assert_editor_state("«fooˇ»\nFOO\nFoo"); + + // Disable case sensitive search. + update_test_editor_settings(&mut cx, &|settings| { + let mut search_settings = SearchSettingsContent::default(); + search_settings.case_sensitive = Some(false); + settings.search = Some(search_settings); + }); + + cx.set_state("fˇoo\nFOO\nFoo"); + cx.update_editor(|e, window, cx| { + e.select_all_matches(&SelectAllMatches, window, cx).unwrap(); + }); + cx.assert_editor_state("«fooˇ»\n«FOOˇ»\n«Fooˇ»"); +} + +#[gpui::test] +async fn test_select_all_matches_does_not_scroll(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + let large_body_1 = "\nd".repeat(200); + let large_body_2 = "\ne".repeat(200); + + cx.set_state(&format!( + "abc\nabc{large_body_1} «ˇa»bc{large_body_2}\nefabc\nabc" + )); + let initial_scroll_position = cx.update_editor(|editor, _, cx| { + let scroll_position = editor.scroll_position(cx); + assert!(scroll_position.y > 0.0, "Initial selection is between two large bodies and should have the editor scrolled to it"); + scroll_position + }); + + cx.update_editor(|editor, window, cx| editor.select_all_matches(&SelectAllMatches, window, cx)) + .unwrap(); + cx.assert_editor_state(&format!( + "«ˇa»bc\n«ˇa»bc{large_body_1} «ˇa»bc{large_body_2}\nef«ˇa»bc\n«ˇa»bc" + )); + cx.update_editor(|editor, _, cx| { + assert_eq!( + editor.scroll_position(cx), + initial_scroll_position, + "Scroll position should not change after selecting all matches" + ) + }); + + // Simulate typing while the selections are active, as that is where the + // editor would attempt to actually scroll to the newest selection, which + // should have been set as the original selection to avoid scrolling to the + // last match. + cx.simulate_keystroke("x"); + cx.update_editor(|editor, _, cx| { + assert_eq!( + editor.scroll_position(cx), + initial_scroll_position, + "Scroll position should not change after editing all matches" + ) + }); + + cx.set_state(&format!( + "abc\nabc{large_body_1} «aˇ»bc{large_body_2}\nefabc\nabc" + )); + let initial_scroll_position = cx.update_editor(|editor, _, cx| { + let scroll_position = editor.scroll_position(cx); + assert!(scroll_position.y > 0.0, "Initial selection is between two large bodies and should have the editor scrolled to it"); + scroll_position + }); + + cx.update_editor(|editor, window, cx| editor.select_all_matches(&SelectAllMatches, window, cx)) + .unwrap(); + cx.assert_editor_state(&format!( + "«aˇ»bc\n«aˇ»bc{large_body_1} «aˇ»bc{large_body_2}\nef«aˇ»bc\n«aˇ»bc" + )); + cx.update_editor(|editor, _, cx| { + assert_eq!( + editor.scroll_position(cx), + initial_scroll_position, + "Scroll position should not change after selecting all matches" + ) + }); + + cx.simulate_keystroke("x"); + cx.update_editor(|editor, _, cx| { + assert_eq!( + editor.scroll_position(cx), + initial_scroll_position, + "Scroll position should not change after editing all matches" + ) + }); +} + +#[gpui::test] +async fn test_undo_format_scrolls_to_last_edit_pos(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + document_formatting_provider: Some(lsp::OneOf::Left(true)), + ..Default::default() + }, + cx, + ) + .await; + + cx.set_state(indoc! {" + line 1 + line 2 + linˇe 3 + line 4 + line 5 + "}); + + // Make an edit + cx.update_editor(|editor, window, cx| { + editor.handle_input("X", window, cx); + }); + + // Move cursor to a different position + cx.update_editor(|editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_ranges([Point::new(4, 2)..Point::new(4, 2)]); + }); + }); + + cx.assert_editor_state(indoc! {" + line 1 + line 2 + linXe 3 + line 4 + liˇne 5 + "}); + + cx.lsp + .set_request_handler::(move |_, _| async move { + Ok(Some(vec![lsp::TextEdit::new( + lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 0)), + "PREFIX ".to_string(), + )])) + }); + + cx.update_editor(|editor, window, cx| editor.format(&Default::default(), window, cx)) + .unwrap() + .await + .unwrap(); + + cx.assert_editor_state(indoc! {" + PREFIX line 1 + line 2 + linXe 3 + line 4 + liˇne 5 + "}); + + // Undo formatting + cx.update_editor(|editor, window, cx| { + editor.undo(&Default::default(), window, cx); + }); + + // Verify cursor moved back to position after edit + cx.assert_editor_state(indoc! {" + line 1 + line 2 + linXˇe 3 + line 4 + line 5 + "}); +} + +#[gpui::test] +async fn test_undo_edit_prediction_scrolls_to_edit_pos(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + let provider = cx.new(|_| FakeEditPredictionDelegate::default()); + cx.update_editor(|editor, window, cx| { + editor.set_edit_prediction_provider( + Some(provider.clone()), + EditPredictionRequestTrigger::EditorCreated, + window, + cx, + ); + }); + + cx.set_state(indoc! {" + line 1 + line 2 + linˇe 3 + line 4 + line 5 + line 6 + line 7 + line 8 + line 9 + line 10 + "}); + + let snapshot = cx.buffer_snapshot(); + let edit_position = snapshot.anchor_after(Point::new(2, 4)); + + cx.update(|_, cx| { + provider.update(cx, |provider, _| { + provider.set_edit_prediction(Some(edit_prediction_types::EditPrediction::Local { + id: None, + edits: vec![(edit_position..edit_position, "X".into())], + cursor_position: None, + edit_preview: None, + })) + }) + }); + + cx.update_editor(|editor, window, cx| editor.update_visible_edit_prediction(window, cx)); + cx.update_editor(|editor, window, cx| { + editor.accept_edit_prediction(&crate::AcceptEditPrediction, window, cx) + }); + + cx.assert_editor_state(indoc! {" + line 1 + line 2 + lineXˇ 3 + line 4 + line 5 + line 6 + line 7 + line 8 + line 9 + line 10 + "}); + + cx.update_editor(|editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_ranges([Point::new(9, 2)..Point::new(9, 2)]); + }); + }); + + cx.assert_editor_state(indoc! {" + line 1 + line 2 + lineX 3 + line 4 + line 5 + line 6 + line 7 + line 8 + line 9 + liˇne 10 + "}); + + cx.update_editor(|editor, window, cx| { + editor.undo(&Default::default(), window, cx); + }); + + cx.assert_editor_state(indoc! {" + line 1 + line 2 + lineˇ 3 + line 4 + line 5 + line 6 + line 7 + line 8 + line 9 + line 10 + "}); +} + +#[gpui::test] +async fn test_select_next_with_multiple_carets(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + cx.set_state( + r#"let foo = 2; +lˇet foo = 2; +let fooˇ = 2; +let foo = 2; +let foo = ˇ2;"#, + ); + + cx.update_editor(|e, window, cx| e.select_next(&SelectNext::default(), window, cx)) + .unwrap(); + cx.assert_editor_state( + r#"let foo = 2; +«letˇ» foo = 2; +let «fooˇ» = 2; +let foo = 2; +let foo = «2ˇ»;"#, + ); + + // noop for multiple selections with different contents + cx.update_editor(|e, window, cx| e.select_next(&SelectNext::default(), window, cx)) + .unwrap(); + cx.assert_editor_state( + r#"let foo = 2; +«letˇ» foo = 2; +let «fooˇ» = 2; +let foo = 2; +let foo = «2ˇ»;"#, + ); + + // Test last selection direction should be preserved + cx.set_state( + r#"let foo = 2; +let foo = 2; +let «fooˇ» = 2; +let «ˇfoo» = 2; +let foo = 2;"#, + ); + + cx.update_editor(|e, window, cx| e.select_next(&SelectNext::default(), window, cx)) + .unwrap(); + cx.assert_editor_state( + r#"let foo = 2; +let foo = 2; +let «fooˇ» = 2; +let «ˇfoo» = 2; +let «ˇfoo» = 2;"#, + ); +} + +#[gpui::test] +async fn test_select_previous_multibuffer(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = + EditorTestContext::new_multibuffer(cx, ["aaa\n«bbb\nccc»\nddd", "aaa\n«bbb\nccc»\nddd"]); + + cx.assert_editor_state(indoc! {" + ˇbbb + ccc + bbb + ccc"}); + cx.dispatch_action(SelectPrevious::default()); + cx.assert_editor_state(indoc! {" + «bbbˇ» + ccc + bbb + ccc"}); + cx.dispatch_action(SelectPrevious::default()); + cx.assert_editor_state(indoc! {" + «bbbˇ» + ccc + «bbbˇ» + ccc"}); +} + +#[gpui::test] +async fn test_select_previous_with_single_caret(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + cx.set_state("abc\nˇabc abc\ndefabc\nabc"); + + cx.update_editor(|e, window, cx| e.select_previous(&SelectPrevious::default(), window, cx)) + .unwrap(); + cx.assert_editor_state("abc\n«abcˇ» abc\ndefabc\nabc"); + + cx.update_editor(|e, window, cx| e.select_previous(&SelectPrevious::default(), window, cx)) + .unwrap(); + cx.assert_editor_state("«abcˇ»\n«abcˇ» abc\ndefabc\nabc"); + + cx.update_editor(|editor, window, cx| editor.undo_selection(&UndoSelection, window, cx)); + cx.assert_editor_state("abc\n«abcˇ» abc\ndefabc\nabc"); + + cx.update_editor(|editor, window, cx| editor.redo_selection(&RedoSelection, window, cx)); + cx.assert_editor_state("«abcˇ»\n«abcˇ» abc\ndefabc\nabc"); + + cx.update_editor(|e, window, cx| e.select_previous(&SelectPrevious::default(), window, cx)) + .unwrap(); + cx.assert_editor_state("«abcˇ»\n«abcˇ» abc\ndefabc\n«abcˇ»"); + + cx.update_editor(|e, window, cx| e.select_previous(&SelectPrevious::default(), window, cx)) + .unwrap(); + cx.assert_editor_state("«abcˇ»\n«abcˇ» «abcˇ»\ndefabc\n«abcˇ»"); +} + +#[gpui::test] +async fn test_select_previous_empty_buffer(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + cx.set_state("aˇ"); + + cx.update_editor(|e, window, cx| e.select_previous(&SelectPrevious::default(), window, cx)) + .unwrap(); + cx.assert_editor_state("«aˇ»"); + cx.update_editor(|e, window, cx| e.select_previous(&SelectPrevious::default(), window, cx)) + .unwrap(); + cx.assert_editor_state("«aˇ»"); +} + +#[gpui::test] +async fn test_select_previous_with_multiple_carets(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + cx.set_state( + r#"let foo = 2; +lˇet foo = 2; +let fooˇ = 2; +let foo = 2; +let foo = ˇ2;"#, + ); + + cx.update_editor(|e, window, cx| e.select_previous(&SelectPrevious::default(), window, cx)) + .unwrap(); + cx.assert_editor_state( + r#"let foo = 2; +«letˇ» foo = 2; +let «fooˇ» = 2; +let foo = 2; +let foo = «2ˇ»;"#, + ); + + // noop for multiple selections with different contents + cx.update_editor(|e, window, cx| e.select_previous(&SelectPrevious::default(), window, cx)) + .unwrap(); + cx.assert_editor_state( + r#"let foo = 2; +«letˇ» foo = 2; +let «fooˇ» = 2; +let foo = 2; +let foo = «2ˇ»;"#, + ); +} + +#[gpui::test] +async fn test_select_previous_with_single_selection(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + + // Enable case sensitive search. + update_test_editor_settings(&mut cx, &|settings| { + let mut search_settings = SearchSettingsContent::default(); + search_settings.case_sensitive = Some(true); + settings.search = Some(search_settings); + }); + + cx.set_state("abc\n«ˇabc» abc\ndefabc\nabc"); + + cx.update_editor(|e, window, cx| e.select_previous(&SelectPrevious::default(), window, cx)) + .unwrap(); + // selection direction is preserved + cx.assert_editor_state("«ˇabc»\n«ˇabc» abc\ndefabc\nabc"); + + cx.update_editor(|e, window, cx| e.select_previous(&SelectPrevious::default(), window, cx)) + .unwrap(); + cx.assert_editor_state("«ˇabc»\n«ˇabc» abc\ndefabc\n«ˇabc»"); + + cx.update_editor(|editor, window, cx| editor.undo_selection(&UndoSelection, window, cx)); + cx.assert_editor_state("«ˇabc»\n«ˇabc» abc\ndefabc\nabc"); + + cx.update_editor(|editor, window, cx| editor.redo_selection(&RedoSelection, window, cx)); + cx.assert_editor_state("«ˇabc»\n«ˇabc» abc\ndefabc\n«ˇabc»"); + + cx.update_editor(|e, window, cx| e.select_previous(&SelectPrevious::default(), window, cx)) + .unwrap(); + cx.assert_editor_state("«ˇabc»\n«ˇabc» abc\ndef«ˇabc»\n«ˇabc»"); + + cx.update_editor(|e, window, cx| e.select_previous(&SelectPrevious::default(), window, cx)) + .unwrap(); + cx.assert_editor_state("«ˇabc»\n«ˇabc» «ˇabc»\ndef«ˇabc»\n«ˇabc»"); + + // Test case sensitivity + cx.set_state("foo\nFOO\nFoo\n«ˇfoo»"); + cx.update_editor(|e, window, cx| { + e.select_previous(&SelectPrevious::default(), window, cx) + .unwrap(); + e.select_previous(&SelectPrevious::default(), window, cx) + .unwrap(); + }); + cx.assert_editor_state("«ˇfoo»\nFOO\nFoo\n«ˇfoo»"); + + // Disable case sensitive search. + update_test_editor_settings(&mut cx, &|settings| { + let mut search_settings = SearchSettingsContent::default(); + search_settings.case_sensitive = Some(false); + settings.search = Some(search_settings); + }); + + cx.set_state("foo\nFOO\n«ˇFoo»"); + cx.update_editor(|e, window, cx| { + e.select_previous(&SelectPrevious::default(), window, cx) + .unwrap(); + e.select_previous(&SelectPrevious::default(), window, cx) + .unwrap(); + }); + cx.assert_editor_state("«ˇfoo»\n«ˇFOO»\n«ˇFoo»"); +} + +#[gpui::test] +async fn test_select_larger_smaller_syntax_node(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let language = Arc::new(Language::new( + LanguageConfig::default(), + Some(tree_sitter_rust::LANGUAGE.into()), + )); + + let text = r#" + use mod1::mod2::{mod3, mod4}; + + fn fn_1(param1: bool, param2: &str) { + let var1 = "text"; + } + "# + .unindent(); + + let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(language, cx)); + let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + let (editor, cx) = cx.add_window_view(|window, cx| build_editor(buffer, window, cx)); + + editor + .condition::(cx, |editor, cx| !editor.buffer.read(cx).is_parsing(cx)) + .await; + + editor.update_in(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(0), 25)..DisplayPoint::new(DisplayRow(0), 25), + DisplayPoint::new(DisplayRow(2), 24)..DisplayPoint::new(DisplayRow(2), 12), + DisplayPoint::new(DisplayRow(3), 18)..DisplayPoint::new(DisplayRow(3), 18), + ]); + }); + editor.select_larger_syntax_node(&SelectLargerSyntaxNode, window, cx); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections( + editor, + indoc! {r#" + use mod1::mod2::{mod3, «mod4ˇ»}; + + fn fn_1«ˇ(param1: bool, param2: &str)» { + let var1 = "«textˇ»"; + } + "#}, + cx, + ); + }); + + editor.update_in(cx, |editor, window, cx| { + editor.select_larger_syntax_node(&SelectLargerSyntaxNode, window, cx); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections( + editor, + indoc! {r#" + use mod1::mod2::«{mod3, mod4}ˇ»; + + «ˇfn fn_1(param1: bool, param2: &str) { + let var1 = "text"; + }» + "#}, + cx, + ); + }); + + editor.update_in(cx, |editor, window, cx| { + editor.select_larger_syntax_node(&SelectLargerSyntaxNode, window, cx); + }); + assert_eq!( + editor.update(cx, |editor, cx| editor + .selections + .display_ranges(&editor.display_snapshot(cx))), + &[DisplayPoint::new(DisplayRow(5), 0)..DisplayPoint::new(DisplayRow(0), 0)] + ); + + // Trying to expand the selected syntax node one more time has no effect. + editor.update_in(cx, |editor, window, cx| { + editor.select_larger_syntax_node(&SelectLargerSyntaxNode, window, cx); + }); + assert_eq!( + editor.update(cx, |editor, cx| editor + .selections + .display_ranges(&editor.display_snapshot(cx))), + &[DisplayPoint::new(DisplayRow(5), 0)..DisplayPoint::new(DisplayRow(0), 0)] + ); + + editor.update_in(cx, |editor, window, cx| { + editor.select_smaller_syntax_node(&SelectSmallerSyntaxNode, window, cx); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections( + editor, + indoc! {r#" + use mod1::mod2::«{mod3, mod4}ˇ»; + + «ˇfn fn_1(param1: bool, param2: &str) { + let var1 = "text"; + }» + "#}, + cx, + ); + }); + + editor.update_in(cx, |editor, window, cx| { + editor.select_smaller_syntax_node(&SelectSmallerSyntaxNode, window, cx); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections( + editor, + indoc! {r#" + use mod1::mod2::{mod3, «mod4ˇ»}; + + fn fn_1«ˇ(param1: bool, param2: &str)» { + let var1 = "«textˇ»"; + } + "#}, + cx, + ); + }); + + editor.update_in(cx, |editor, window, cx| { + editor.select_smaller_syntax_node(&SelectSmallerSyntaxNode, window, cx); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections( + editor, + indoc! {r#" + use mod1::mod2::{mod3, moˇd4}; + + fn fn_1(para«ˇm1: bool, pa»ram2: &str) { + let var1 = "teˇxt"; + } + "#}, + cx, + ); + }); + + // Trying to shrink the selected syntax node one more time has no effect. + editor.update_in(cx, |editor, window, cx| { + editor.select_smaller_syntax_node(&SelectSmallerSyntaxNode, window, cx); + }); + editor.update_in(cx, |editor, _, cx| { + assert_text_with_selections( + editor, + indoc! {r#" + use mod1::mod2::{mod3, moˇd4}; + + fn fn_1(para«ˇm1: bool, pa»ram2: &str) { + let var1 = "teˇxt"; + } + "#}, + cx, + ); + }); + + // Ensure that we keep expanding the selection if the larger selection starts or ends within + // a fold. + editor.update_in(cx, |editor, window, cx| { + editor.fold_creases( + vec![ + Crease::simple( + Point::new(0, 21)..Point::new(0, 24), + FoldPlaceholder::test(), + ), + Crease::simple( + Point::new(3, 20)..Point::new(3, 22), + FoldPlaceholder::test(), + ), + ], + true, + window, + cx, + ); + editor.select_larger_syntax_node(&SelectLargerSyntaxNode, window, cx); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections( + editor, + indoc! {r#" + use mod1::mod2::«{mod3, mod4}ˇ»; + + fn fn_1«ˇ(param1: bool, param2: &str)» { + let var1 = "«textˇ»"; + } + "#}, + cx, + ); + }); + + // Ensure multiple cursors have consistent direction after expanding + editor.update_in(cx, |editor, window, cx| { + editor.unfold_all(&UnfoldAll, window, cx); + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(0), 25)..DisplayPoint::new(DisplayRow(0), 25), + DisplayPoint::new(DisplayRow(3), 18)..DisplayPoint::new(DisplayRow(3), 18), + ]); + }); + editor.select_larger_syntax_node(&SelectLargerSyntaxNode, window, cx); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections( + editor, + indoc! {r#" + use mod1::mod2::{mod3, «mod4ˇ»}; + + fn fn_1(param1: bool, param2: &str) { + let var1 = "«textˇ»"; + } + "#}, + cx, + ); + }); +} + +#[gpui::test] +async fn test_select_larger_syntax_node_for_cursor_at_end(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let language = Arc::new(Language::new( + LanguageConfig::default(), + Some(tree_sitter_rust::LANGUAGE.into()), + )); + + let text = "let a = 2;"; + + let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(language, cx)); + let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + let (editor, cx) = cx.add_window_view(|window, cx| build_editor(buffer, window, cx)); + + editor + .condition::(cx, |editor, cx| !editor.buffer.read(cx).is_parsing(cx)) + .await; + + // Test case 1: Cursor at end of word + editor.update_in(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(0), 5)..DisplayPoint::new(DisplayRow(0), 5) + ]); + }); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections(editor, "let aˇ = 2;", cx); + }); + editor.update_in(cx, |editor, window, cx| { + editor.select_larger_syntax_node(&SelectLargerSyntaxNode, window, cx); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections(editor, "let «ˇa» = 2;", cx); + }); + editor.update_in(cx, |editor, window, cx| { + editor.select_larger_syntax_node(&SelectLargerSyntaxNode, window, cx); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections(editor, "«ˇlet a = 2;»", cx); + }); + + // Test case 2: Cursor at end of statement + editor.update_in(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(0), 11)..DisplayPoint::new(DisplayRow(0), 11) + ]); + }); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections(editor, "let a = 2;ˇ", cx); + }); + editor.update_in(cx, |editor, window, cx| { + editor.select_larger_syntax_node(&SelectLargerSyntaxNode, window, cx); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections(editor, "«ˇlet a = 2;»", cx); + }); +} + +#[gpui::test] +async fn test_select_larger_syntax_node_for_cursor_at_symbol(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let language = Arc::new(Language::new( + LanguageConfig { + name: "JavaScript".into(), + ..Default::default() + }, + Some(tree_sitter_typescript::LANGUAGE_TSX.into()), + )); + + let text = r#" + let a = { + key: "value", + }; + "# + .unindent(); + + let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(language, cx)); + let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + let (editor, cx) = cx.add_window_view(|window, cx| build_editor(buffer, window, cx)); + + editor + .condition::(cx, |editor, cx| !editor.buffer.read(cx).is_parsing(cx)) + .await; + + // Test case 1: Cursor after '{' + editor.update_in(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(0), 9)..DisplayPoint::new(DisplayRow(0), 9) + ]); + }); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections( + editor, + indoc! {r#" + let a = {ˇ + key: "value", + }; + "#}, + cx, + ); + }); + editor.update_in(cx, |editor, window, cx| { + editor.select_larger_syntax_node(&SelectLargerSyntaxNode, window, cx); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections( + editor, + indoc! {r#" + let a = «ˇ{ + key: "value", + }»; + "#}, + cx, + ); + }); + + // Test case 2: Cursor after ':' + editor.update_in(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(1), 8)..DisplayPoint::new(DisplayRow(1), 8) + ]); + }); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections( + editor, + indoc! {r#" + let a = { + key:ˇ "value", + }; + "#}, + cx, + ); + }); + editor.update_in(cx, |editor, window, cx| { + editor.select_larger_syntax_node(&SelectLargerSyntaxNode, window, cx); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections( + editor, + indoc! {r#" + let a = { + «ˇkey: "value"», + }; + "#}, + cx, + ); + }); + editor.update_in(cx, |editor, window, cx| { + editor.select_larger_syntax_node(&SelectLargerSyntaxNode, window, cx); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections( + editor, + indoc! {r#" + let a = «ˇ{ + key: "value", + }»; + "#}, + cx, + ); + }); + + // Test case 3: Cursor after ',' + editor.update_in(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(1), 17)..DisplayPoint::new(DisplayRow(1), 17) + ]); + }); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections( + editor, + indoc! {r#" + let a = { + key: "value",ˇ + }; + "#}, + cx, + ); + }); + editor.update_in(cx, |editor, window, cx| { + editor.select_larger_syntax_node(&SelectLargerSyntaxNode, window, cx); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections( + editor, + indoc! {r#" + let a = «ˇ{ + key: "value", + }»; + "#}, + cx, + ); + }); + + // Test case 4: Cursor after ';' + editor.update_in(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(2), 2)..DisplayPoint::new(DisplayRow(2), 2) + ]); + }); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections( + editor, + indoc! {r#" + let a = { + key: "value", + };ˇ + "#}, + cx, + ); + }); + editor.update_in(cx, |editor, window, cx| { + editor.select_larger_syntax_node(&SelectLargerSyntaxNode, window, cx); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections( + editor, + indoc! {r#" + «ˇlet a = { + key: "value", + }; + »"#}, + cx, + ); + }); +} + +#[gpui::test] +async fn test_select_larger_smaller_syntax_node_for_string(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let language = Arc::new(Language::new( + LanguageConfig::default(), + Some(tree_sitter_rust::LANGUAGE.into()), + )); + + let text = r#" + use mod1::mod2::{mod3, mod4}; + + fn fn_1(param1: bool, param2: &str) { + let var1 = "hello world"; + } + "# + .unindent(); + + let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(language, cx)); + let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + let (editor, cx) = cx.add_window_view(|window, cx| build_editor(buffer, window, cx)); + + editor + .condition::(cx, |editor, cx| !editor.buffer.read(cx).is_parsing(cx)) + .await; + + // Test 1: Cursor on a letter of a string word + editor.update_in(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(3), 17)..DisplayPoint::new(DisplayRow(3), 17) + ]); + }); + }); + editor.update_in(cx, |editor, window, cx| { + assert_text_with_selections( + editor, + indoc! {r#" + use mod1::mod2::{mod3, mod4}; + + fn fn_1(param1: bool, param2: &str) { + let var1 = "hˇello world"; + } + "#}, + cx, + ); + editor.select_larger_syntax_node(&SelectLargerSyntaxNode, window, cx); + assert_text_with_selections( + editor, + indoc! {r#" + use mod1::mod2::{mod3, mod4}; + + fn fn_1(param1: bool, param2: &str) { + let var1 = "«ˇhello» world"; + } + "#}, + cx, + ); + }); + + // Test 2: Partial selection within a word + editor.update_in(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(3), 17)..DisplayPoint::new(DisplayRow(3), 19) + ]); + }); + }); + editor.update_in(cx, |editor, window, cx| { + assert_text_with_selections( + editor, + indoc! {r#" + use mod1::mod2::{mod3, mod4}; + + fn fn_1(param1: bool, param2: &str) { + let var1 = "h«elˇ»lo world"; + } + "#}, + cx, + ); + editor.select_larger_syntax_node(&SelectLargerSyntaxNode, window, cx); + assert_text_with_selections( + editor, + indoc! {r#" + use mod1::mod2::{mod3, mod4}; + + fn fn_1(param1: bool, param2: &str) { + let var1 = "«ˇhello» world"; + } + "#}, + cx, + ); + }); + + // Test 3: Complete word already selected + editor.update_in(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(3), 16)..DisplayPoint::new(DisplayRow(3), 21) + ]); + }); + }); + editor.update_in(cx, |editor, window, cx| { + assert_text_with_selections( + editor, + indoc! {r#" + use mod1::mod2::{mod3, mod4}; + + fn fn_1(param1: bool, param2: &str) { + let var1 = "«helloˇ» world"; + } + "#}, + cx, + ); + editor.select_larger_syntax_node(&SelectLargerSyntaxNode, window, cx); + assert_text_with_selections( + editor, + indoc! {r#" + use mod1::mod2::{mod3, mod4}; + + fn fn_1(param1: bool, param2: &str) { + let var1 = "«hello worldˇ»"; + } + "#}, + cx, + ); + }); + + // Test 4: Selection spanning across words + editor.update_in(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(3), 19)..DisplayPoint::new(DisplayRow(3), 24) + ]); + }); + }); + editor.update_in(cx, |editor, window, cx| { + assert_text_with_selections( + editor, + indoc! {r#" + use mod1::mod2::{mod3, mod4}; + + fn fn_1(param1: bool, param2: &str) { + let var1 = "hel«lo woˇ»rld"; + } + "#}, + cx, + ); + editor.select_larger_syntax_node(&SelectLargerSyntaxNode, window, cx); + assert_text_with_selections( + editor, + indoc! {r#" + use mod1::mod2::{mod3, mod4}; + + fn fn_1(param1: bool, param2: &str) { + let var1 = "«ˇhello world»"; + } + "#}, + cx, + ); + }); + + // Test 5: Expansion beyond string + editor.update_in(cx, |editor, window, cx| { + editor.select_larger_syntax_node(&SelectLargerSyntaxNode, window, cx); + editor.select_larger_syntax_node(&SelectLargerSyntaxNode, window, cx); + assert_text_with_selections( + editor, + indoc! {r#" + use mod1::mod2::{mod3, mod4}; + + fn fn_1(param1: bool, param2: &str) { + «ˇlet var1 = "hello world";» + } + "#}, + cx, + ); + }); +} + +#[gpui::test] +async fn test_unwrap_syntax_nodes(cx: &mut gpui::TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + let language = Arc::new(Language::new( + LanguageConfig::default(), + Some(tree_sitter_rust::LANGUAGE.into()), + )); + + cx.update_buffer(|buffer, cx| { + buffer.set_language(Some(language), cx); + }); + + cx.set_state(indoc! { r#"use mod1::{mod2::{«mod3ˇ», mod4}, mod5::{mod6, «mod7ˇ»}};"# }); + cx.update_editor(|editor, window, cx| { + editor.unwrap_syntax_node(&UnwrapSyntaxNode, window, cx); + }); + + cx.assert_editor_state(indoc! { r#"use mod1::{mod2::«mod3ˇ», mod5::«mod7ˇ»};"# }); + + cx.set_state(indoc! { r#"fn a() { + // what + // a + // ˇlong + // method + // I + // sure + // hope + // it + // works + }"# }); + + let buffer = cx.update_multibuffer(|multibuffer, _| multibuffer.as_singleton().unwrap()); + let multi_buffer = cx.new(|_| MultiBuffer::new(Capability::ReadWrite)); + cx.update(|_, cx| { + multi_buffer.update(cx, |multi_buffer, cx| { + multi_buffer.set_excerpts_for_path( + PathKey::for_buffer(&buffer, cx), + buffer, + [Point::new(1, 0)..Point::new(1, 0)], + 3, + cx, + ); + }); + }); + + let editor2 = cx.new_window_entity(|window, cx| { + Editor::new(EditorMode::full(), multi_buffer, None, window, cx) + }); + + let mut cx = EditorTestContext::for_editor_in(editor2, &mut cx).await; + cx.update_editor(|editor, window, cx| { + editor.change_selections(SelectionEffects::default(), window, cx, |s| { + s.select_ranges([Point::new(3, 0)..Point::new(3, 0)]); + }) + }); + + cx.assert_editor_state(indoc! { " + fn a() { + // what + // a + ˇ // long + // method"}); + + cx.update_editor(|editor, window, cx| { + editor.unwrap_syntax_node(&UnwrapSyntaxNode, window, cx); + }); + + // Although we could potentially make the action work when the syntax node + // is half-hidden, it seems a bit dangerous as you can't easily tell what it + // did. Maybe we could also expand the excerpt to contain the range? + cx.assert_editor_state(indoc! { " + fn a() { + // what + // a + ˇ // long + // method"}); +} + +#[gpui::test] +async fn test_fold_function_bodies(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let base_text = r#" + impl A { + // this is an uncommitted comment + + fn b() { + c(); + } + + // this is another uncommitted comment + + fn d() { + // e + // f + } + } + + fn g() { + // h + } + "# + .unindent(); + + let text = r#" + ˇimpl A { + + fn b() { + c(); + } + + fn d() { + // e + // f + } + } + + fn g() { + // h + } + "# + .unindent(); + + let mut cx = EditorLspTestContext::new_rust(Default::default(), cx).await; + cx.set_state(&text); + cx.set_head_text(&base_text); + cx.update_editor(|editor, window, cx| { + editor.expand_all_diff_hunks(&Default::default(), window, cx); + }); + + cx.assert_state_with_diff( + " + ˇimpl A { + - // this is an uncommitted comment + + fn b() { + c(); + } + + - // this is another uncommitted comment + - + fn d() { + // e + // f + } + } + + fn g() { + // h + } + " + .unindent(), + ); + + let expected_display_text = " + impl A { + // this is an uncommitted comment + + fn b() { + ⋯ + } + + // this is another uncommitted comment + + fn d() { + ⋯ + } + } + + fn g() { + ⋯ + } + " + .unindent(); + + cx.update_editor(|editor, window, cx| { + editor.fold_function_bodies(&FoldFunctionBodies, window, cx); + assert_eq!(editor.display_text(cx), expected_display_text); + }); +} + +#[gpui::test] +async fn test_autoindent(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let language = Arc::new( + Language::new( + LanguageConfig { + brackets: BracketPairConfig { + pairs: vec![ + BracketPair { + start: "{".to_string(), + end: "}".to_string(), + close: false, + surround: false, + newline: true, + }, + BracketPair { + start: "(".to_string(), + end: ")".to_string(), + close: false, + surround: false, + newline: true, + }, + ], + ..Default::default() + }, + ..Default::default() + }, + Some(tree_sitter_rust::LANGUAGE.into()), + ) + .with_indents_query( + r#" + (_ "(" ")" @end) @indent + (_ "{" "}" @end) @indent + "#, + ) + .unwrap(), + ); + + let text = "fn a() {}"; + + let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(language, cx)); + let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + let (editor, cx) = cx.add_window_view(|window, cx| build_editor(buffer, window, cx)); + editor + .condition::(cx, |editor, cx| !editor.buffer.read(cx).is_parsing(cx)) + .await; + + editor.update_in(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_ranges([ + MultiBufferOffset(5)..MultiBufferOffset(5), + MultiBufferOffset(8)..MultiBufferOffset(8), + MultiBufferOffset(9)..MultiBufferOffset(9), + ]) + }); + editor.newline(&Newline, window, cx); + assert_eq!(editor.text(cx), "fn a(\n \n) {\n \n}\n"); + assert_eq!( + editor.selections.ranges(&editor.display_snapshot(cx)), + &[ + Point::new(1, 4)..Point::new(1, 4), + Point::new(3, 4)..Point::new(3, 4), + Point::new(5, 0)..Point::new(5, 0) + ] + ); + }); +} + +#[gpui::test] +async fn test_autoindent_disabled(cx: &mut TestAppContext) { + init_test(cx, |settings| { + settings.defaults.auto_indent = Some(settings::AutoIndentMode::None) + }); + + let language = Arc::new( + Language::new( + LanguageConfig { + brackets: BracketPairConfig { + pairs: vec![ + BracketPair { + start: "{".to_string(), + end: "}".to_string(), + close: false, + surround: false, + newline: true, + }, + BracketPair { + start: "(".to_string(), + end: ")".to_string(), + close: false, + surround: false, + newline: true, + }, + ], + ..Default::default() + }, + ..Default::default() + }, + Some(tree_sitter_rust::LANGUAGE.into()), + ) + .with_indents_query( + r#" + (_ "(" ")" @end) @indent + (_ "{" "}" @end) @indent + "#, + ) + .unwrap(), + ); + + let text = "fn a() {}"; + + let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(language, cx)); + let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + let (editor, cx) = cx.add_window_view(|window, cx| build_editor(buffer, window, cx)); + editor + .condition::(cx, |editor, cx| !editor.buffer.read(cx).is_parsing(cx)) + .await; + + editor.update_in(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_ranges([ + MultiBufferOffset(5)..MultiBufferOffset(5), + MultiBufferOffset(8)..MultiBufferOffset(8), + MultiBufferOffset(9)..MultiBufferOffset(9), + ]) + }); + editor.newline(&Newline, window, cx); + assert_eq!( + editor.text(cx), + indoc!( + " + fn a( + + ) { + + } + " + ) + ); + assert_eq!( + editor.selections.ranges(&editor.display_snapshot(cx)), + &[ + Point::new(1, 0)..Point::new(1, 0), + Point::new(3, 0)..Point::new(3, 0), + Point::new(5, 0)..Point::new(5, 0) + ] + ); + }); +} + +#[gpui::test] +async fn test_autoindent_none_does_not_preserve_indentation_on_newline(cx: &mut TestAppContext) { + init_test(cx, |settings| { + settings.defaults.auto_indent = Some(settings::AutoIndentMode::None) + }); + + let mut cx = EditorTestContext::new(cx).await; + + cx.set_state(indoc! {" + hello + indented lineˇ + world + "}); + + cx.update_editor(|editor, window, cx| { + editor.newline(&Newline, window, cx); + }); + + cx.assert_editor_state(indoc! {" + hello + indented line + ˇ + world + "}); +} + +#[gpui::test] +async fn test_autoindent_preserve_indent_maintains_indentation_on_newline(cx: &mut TestAppContext) { + // When auto_indent is "preserve_indent", pressing Enter on an indented line + // should preserve the indentation but not adjust based on syntax. + init_test(cx, |settings| { + settings.defaults.auto_indent = Some(settings::AutoIndentMode::PreserveIndent) + }); + + let mut cx = EditorTestContext::new(cx).await; + + cx.set_state(indoc! {" + hello + indented lineˇ + world + "}); + + cx.update_editor(|editor, window, cx| { + editor.newline(&Newline, window, cx); + }); + + // The new line SHOULD have the same indentation as the previous line + cx.assert_editor_state(indoc! {" + hello + indented line + ˇ + world + "}); +} + +#[gpui::test] +async fn test_autoindent_preserve_indent_does_not_apply_syntax_indent(cx: &mut TestAppContext) { + init_test(cx, |settings| { + settings.defaults.auto_indent = Some(settings::AutoIndentMode::PreserveIndent) + }); + + let language = Arc::new( + Language::new( + LanguageConfig { + brackets: BracketPairConfig { + pairs: vec![BracketPair { + start: "{".to_string(), + end: "}".to_string(), + close: false, + surround: false, + newline: false, // Disable extra newline behavior to isolate syntax indent test + }], + ..Default::default() + }, + ..Default::default() + }, + Some(tree_sitter_rust::LANGUAGE.into()), + ) + .with_indents_query(r#"(_ "{" "}" @end) @indent"#) + .unwrap(), + ); + + let buffer = + cx.new(|cx| Buffer::local("fn foo() {\n}", cx).with_language(language.clone(), cx)); + let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + let (editor, cx) = cx.add_window_view(|window, cx| build_editor(buffer, window, cx)); + editor + .condition::(cx, |editor, cx| !editor.buffer.read(cx).is_parsing(cx)) + .await; + + // Position cursor at end of line containing `{` + editor.update_in(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_ranges([MultiBufferOffset(10)..MultiBufferOffset(10)]) // After "fn foo() {" + }); + editor.newline(&Newline, window, cx); + + // With PreserveIndent, the new line should have 0 indentation (same as the fn line) + // NOT 4 spaces (which tree-sitter would add for being inside `{}`) + assert_eq!(editor.text(cx), "fn foo() {\n\n}"); + }); +} + +#[gpui::test] +async fn test_autoindent_syntax_aware_applies_syntax_indent(cx: &mut TestAppContext) { + // Companion test to show that SyntaxAware DOES apply tree-sitter indentation + init_test(cx, |settings| { + settings.defaults.auto_indent = Some(settings::AutoIndentMode::SyntaxAware) + }); + + let language = Arc::new( + Language::new( + LanguageConfig { + brackets: BracketPairConfig { + pairs: vec![BracketPair { + start: "{".to_string(), + end: "}".to_string(), + close: false, + surround: false, + newline: false, // Disable extra newline behavior to isolate syntax indent test + }], + ..Default::default() + }, + ..Default::default() + }, + Some(tree_sitter_rust::LANGUAGE.into()), + ) + .with_indents_query(r#"(_ "{" "}" @end) @indent"#) + .unwrap(), + ); + + let buffer = + cx.new(|cx| Buffer::local("fn foo() {\n}", cx).with_language(language.clone(), cx)); + let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + let (editor, cx) = cx.add_window_view(|window, cx| build_editor(buffer, window, cx)); + editor + .condition::(cx, |editor, cx| !editor.buffer.read(cx).is_parsing(cx)) + .await; + + // Position cursor at end of line containing `{` + editor.update_in(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_ranges([MultiBufferOffset(10)..MultiBufferOffset(10)]) // After "fn foo() {" + }); + editor.newline(&Newline, window, cx); + + // With SyntaxAware, tree-sitter adds indentation for being inside `{}` + assert_eq!(editor.text(cx), "fn foo() {\n \n}"); + }); +} + +#[gpui::test] +async fn test_ime_composition_keeps_manual_indent(cx: &mut TestAppContext) { + init_test(cx, |settings| { + settings.defaults.auto_indent = Some(settings::AutoIndentMode::SyntaxAware) + }); + + let mut cx = EditorTestContext::new(cx).await; + let language = Arc::new( + Language::new( + LanguageConfig { + brackets: BracketPairConfig { + pairs: vec![BracketPair { + start: "{".to_string(), + end: "}".to_string(), + close: false, + surround: false, + newline: false, + }], + ..Default::default() + }, + ..Default::default() + }, + Some(tree_sitter_rust::LANGUAGE.into()), + ) + .with_indents_query(r#"(_ "{" "}" @end) @indent"#) + .unwrap(), + ); + cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); + + cx.set_state("fn foo() {ˇ\n}"); + cx.condition(|editor, cx| !editor.buffer.read(cx).is_parsing(cx)) + .await; + + cx.update_editor(|editor, window, cx| { + editor.newline(&Newline, window, cx); + // A manual indent here + editor.tab(&Tab, window, cx); + }); + assert_eq!(cx.buffer_text(), "fn foo() {\n \n}"); + + cx.update_editor(|editor, window, cx| { + editor.replace_and_mark_text_in_range(None, "n", None, window, cx); + editor.replace_and_mark_text_in_range(None, "nn", None, window, cx); + }); + + cx.run_until_parked(); + + assert_eq!(cx.buffer_text(), "fn foo() {\n nn\n}"); +} + +#[gpui::test] +async fn test_autoindent_disabled_with_nested_language(cx: &mut TestAppContext) { + init_test(cx, |settings| { + settings.defaults.auto_indent = Some(settings::AutoIndentMode::SyntaxAware); + settings.languages.0.insert( + "python".into(), + LanguageSettingsContent { + auto_indent: Some(settings::AutoIndentMode::None), + ..Default::default() + }, + ); + }); + + let mut cx = EditorTestContext::new(cx).await; + + let injected_language = Arc::new( + Language::new( + LanguageConfig { + brackets: BracketPairConfig { + pairs: vec![ + BracketPair { + start: "{".to_string(), + end: "}".to_string(), + close: false, + surround: false, + newline: true, + }, + BracketPair { + start: "(".to_string(), + end: ")".to_string(), + close: true, + surround: false, + newline: true, + }, + ], + ..Default::default() + }, + name: "python".into(), + ..Default::default() + }, + Some(tree_sitter_python::LANGUAGE.into()), + ) + .with_indents_query( + r#" + (_ "(" ")" @end) @indent + (_ "{" "}" @end) @indent + "#, + ) + .unwrap(), + ); + + let language = Arc::new( + Language::new( + LanguageConfig { + brackets: BracketPairConfig { + pairs: vec![ + BracketPair { + start: "{".to_string(), + end: "}".to_string(), + close: false, + surround: false, + newline: true, + }, + BracketPair { + start: "(".to_string(), + end: ")".to_string(), + close: true, + surround: false, + newline: true, + }, + ], + ..Default::default() + }, + name: LanguageName::new_static("rust"), + ..Default::default() + }, + Some(tree_sitter_rust::LANGUAGE.into()), + ) + .with_indents_query( + r#" + (_ "(" ")" @end) @indent + (_ "{" "}" @end) @indent + "#, + ) + .unwrap() + .with_injection_query( + r#" + (macro_invocation + macro: (identifier) @_macro_name + (token_tree) @injection.content + (#set! injection.language "python")) + "#, + ) + .unwrap(), + ); + + cx.language_registry().add(injected_language); + cx.language_registry().add(language.clone()); + + cx.update_buffer(|buffer, cx| { + buffer.set_language(Some(language), cx); + }); + + cx.set_state(r#"struct A {ˇ}"#); + + cx.update_editor(|editor, window, cx| { + editor.newline(&Default::default(), window, cx); + }); + + cx.assert_editor_state(indoc!( + "struct A { + ˇ + }" + )); + + cx.set_state(r#"select_biased!(ˇ)"#); + + cx.update_editor(|editor, window, cx| { + editor.newline(&Default::default(), window, cx); + editor.handle_input("def ", window, cx); + editor.handle_input("(", window, cx); + editor.newline(&Default::default(), window, cx); + editor.handle_input("a", window, cx); + }); + + cx.assert_editor_state(indoc!( + "select_biased!( + def ( + aˇ + ) + )" + )); +} + +#[gpui::test] +async fn test_autoindent_selections(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + { + let mut cx = EditorLspTestContext::new_rust(Default::default(), cx).await; + cx.set_state(indoc! {" + impl A { + + fn b() {} + + «fn c() { + + }ˇ» + } + "}); + + cx.update_editor(|editor, window, cx| { + editor.autoindent(&Default::default(), window, cx); + }); + cx.wait_for_autoindent_applied().await; + + cx.assert_editor_state(indoc! {" + impl A { + + fn b() {} + + «fn c() { + + }ˇ» + } + "}); + } + + { + let mut cx = EditorTestContext::new_multibuffer( + cx, + [indoc! { " + impl A { + « + // a + fn b(){} + » + « + } + fn c(){} + » + "}], + ); + + let buffer = cx.update_editor(|editor, _, cx| { + let buffer = editor.buffer().update(cx, |buffer, _| { + buffer.all_buffers().iter().next().unwrap().clone() + }); + buffer.update(cx, |buffer, cx| buffer.set_language(Some(rust_lang()), cx)); + buffer + }); + + cx.run_until_parked(); + cx.update_editor(|editor, window, cx| { + editor.select_all(&Default::default(), window, cx); + editor.autoindent(&Default::default(), window, cx) + }); + cx.run_until_parked(); + + cx.update(|_, cx| { + assert_eq!( + buffer.read(cx).text(), + indoc! { " + impl A { + + // a + fn b(){} + + + } + fn c(){} + + " } + ) + }); + } +} + +#[gpui::test] +async fn test_autoclose_nested_brackets_in_plain_text(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + + let language = PLAIN_TEXT.clone(); + cx.language_registry().add(language.clone()); + cx.update_buffer(|buffer, cx| { + buffer.set_language(Some(language), cx); + }); + + cx.set_state("ˇ"); + + // Typing an opening bracket right before an existing closing bracket should + // still autoclose, not skip because the following char is `)`. + cx.update_editor(|editor, window, cx| { + editor.handle_input("(", window, cx); + editor.handle_input("[", window, cx); + editor.handle_input("{", window, cx); + }); + cx.assert_editor_state("([{ˇ}])"); +} + +#[gpui::test] +async fn test_autoclose_and_auto_surround_pairs(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + let language = Arc::new(Language::new( + LanguageConfig { + brackets: BracketPairConfig { + pairs: vec![ + BracketPair { + start: "{".to_string(), + end: "}".to_string(), + close: true, + surround: true, + newline: true, + }, + BracketPair { + start: "(".to_string(), + end: ")".to_string(), + close: true, + surround: true, + newline: true, + }, + BracketPair { + start: "/*".to_string(), + end: " */".to_string(), + close: true, + surround: true, + newline: true, + }, + BracketPair { + start: "[".to_string(), + end: "]".to_string(), + close: false, + surround: false, + newline: true, + }, + BracketPair { + start: "\"".to_string(), + end: "\"".to_string(), + close: true, + surround: true, + newline: false, + }, + BracketPair { + start: "<".to_string(), + end: ">".to_string(), + close: false, + surround: true, + newline: true, + }, + ], + ..Default::default() + }, + autoclose_before: "})]".to_string(), + ..Default::default() + }, + Some(tree_sitter_rust::LANGUAGE.into()), + )); + + cx.language_registry().add(language.clone()); + cx.update_buffer(|buffer, cx| { + buffer.set_language(Some(language), cx); + }); + + cx.set_state( + &r#" + 🏀ˇ + εˇ + ❤️ˇ + "# + .unindent(), + ); + + // autoclose multiple nested brackets at multiple cursors + cx.update_editor(|editor, window, cx| { + editor.handle_input("{", window, cx); + editor.handle_input("{", window, cx); + editor.handle_input("{", window, cx); + }); + cx.assert_editor_state( + &" + 🏀{{{ˇ}}} + ε{{{ˇ}}} + ❤️{{{ˇ}}} + " + .unindent(), + ); + + // insert a different closing bracket + cx.update_editor(|editor, window, cx| { + editor.handle_input(")", window, cx); + }); + cx.assert_editor_state( + &" + 🏀{{{)ˇ}}} + ε{{{)ˇ}}} + ❤️{{{)ˇ}}} + " + .unindent(), + ); + + // skip over the auto-closed brackets when typing a closing bracket + cx.update_editor(|editor, window, cx| { + editor.move_right(&MoveRight, window, cx); + editor.handle_input("}", window, cx); + editor.handle_input("}", window, cx); + editor.handle_input("}", window, cx); + }); + cx.assert_editor_state( + &" + 🏀{{{)}}}}ˇ + ε{{{)}}}}ˇ + ❤️{{{)}}}}ˇ + " + .unindent(), + ); + + // autoclose multi-character pairs + cx.set_state( + &" + ˇ + ˇ + " + .unindent(), + ); + cx.update_editor(|editor, window, cx| { + editor.handle_input("/", window, cx); + editor.handle_input("*", window, cx); + }); + cx.assert_editor_state( + &" + /*ˇ */ + /*ˇ */ + " + .unindent(), + ); + + // one cursor autocloses a multi-character pair, one cursor + // does not autoclose. + cx.set_state( + &" + /ˇ + ˇ + " + .unindent(), + ); + cx.update_editor(|editor, window, cx| editor.handle_input("*", window, cx)); + cx.assert_editor_state( + &" + /*ˇ */ + *ˇ + " + .unindent(), + ); + + // Don't autoclose if the next character isn't whitespace and isn't + // listed in the language's "autoclose_before" section. + cx.set_state("ˇa b"); + cx.update_editor(|editor, window, cx| editor.handle_input("{", window, cx)); + cx.assert_editor_state("{ˇa b"); + + // Don't autoclose if `close` is false for the bracket pair + cx.set_state("ˇ"); + cx.update_editor(|editor, window, cx| editor.handle_input("[", window, cx)); + cx.assert_editor_state("[ˇ"); + + // Surround with brackets if text is selected + cx.set_state("«aˇ» b"); + cx.update_editor(|editor, window, cx| editor.handle_input("{", window, cx)); + cx.assert_editor_state("{«aˇ»} b"); + + // Autoclose when not immediately after a word character + cx.set_state("a ˇ"); + cx.update_editor(|editor, window, cx| editor.handle_input("\"", window, cx)); + cx.assert_editor_state("a \"ˇ\""); + + // Autoclose pair where the start and end characters are the same + cx.update_editor(|editor, window, cx| editor.handle_input("\"", window, cx)); + cx.assert_editor_state("a \"\"ˇ"); + + // Don't autoclose when immediately after a word character + cx.set_state("aˇ"); + cx.update_editor(|editor, window, cx| editor.handle_input("\"", window, cx)); + cx.assert_editor_state("a\"ˇ"); + + // Do autoclose when after a non-word character + cx.set_state("{ˇ"); + cx.update_editor(|editor, window, cx| editor.handle_input("\"", window, cx)); + cx.assert_editor_state("{\"ˇ\""); + + // Non identical pairs autoclose regardless of preceding character + cx.set_state("aˇ"); + cx.update_editor(|editor, window, cx| editor.handle_input("{", window, cx)); + cx.assert_editor_state("a{ˇ}"); + + // Don't autoclose pair if autoclose is disabled + cx.set_state("ˇ"); + cx.update_editor(|editor, window, cx| editor.handle_input("<", window, cx)); + cx.assert_editor_state("<ˇ"); + + // Surround with brackets if text is selected and auto_surround is enabled, even if autoclose is disabled + cx.set_state("«aˇ» b"); + cx.update_editor(|editor, window, cx| editor.handle_input("<", window, cx)); + cx.assert_editor_state("<«aˇ»> b"); +} + +#[gpui::test] +async fn test_always_treat_brackets_as_autoclosed_skip_over(cx: &mut TestAppContext) { + init_test(cx, |settings| { + settings.defaults.always_treat_brackets_as_autoclosed = Some(true); + }); + + let mut cx = EditorTestContext::new(cx).await; + + let language = Arc::new(Language::new( + LanguageConfig { + brackets: BracketPairConfig { + pairs: vec![ + BracketPair { + start: "{".to_string(), + end: "}".to_string(), + close: true, + surround: true, + newline: true, + }, + BracketPair { + start: "(".to_string(), + end: ")".to_string(), + close: true, + surround: true, + newline: true, + }, + BracketPair { + start: "[".to_string(), + end: "]".to_string(), + close: false, + surround: false, + newline: true, + }, + ], + ..Default::default() + }, + autoclose_before: "})]".to_string(), + ..Default::default() + }, + Some(tree_sitter_rust::LANGUAGE.into()), + )); + + cx.language_registry().add(language.clone()); + cx.update_buffer(|buffer, cx| { + buffer.set_language(Some(language), cx); + }); + + cx.set_state( + &" + ˇ + ˇ + ˇ + " + .unindent(), + ); + + // ensure only matching closing brackets are skipped over + cx.update_editor(|editor, window, cx| { + editor.handle_input("}", window, cx); + editor.move_left(&MoveLeft, window, cx); + editor.handle_input(")", window, cx); + editor.move_left(&MoveLeft, window, cx); + }); + cx.assert_editor_state( + &" + ˇ)} + ˇ)} + ˇ)} + " + .unindent(), + ); + + // skip-over closing brackets at multiple cursors + cx.update_editor(|editor, window, cx| { + editor.handle_input(")", window, cx); + editor.handle_input("}", window, cx); + }); + cx.assert_editor_state( + &" + )}ˇ + )}ˇ + )}ˇ + " + .unindent(), + ); + + // ignore non-close brackets + cx.update_editor(|editor, window, cx| { + editor.handle_input("]", window, cx); + editor.move_left(&MoveLeft, window, cx); + editor.handle_input("]", window, cx); + }); + cx.assert_editor_state( + &" + )}]ˇ] + )}]ˇ] + )}]ˇ] + " + .unindent(), + ); +} + +#[gpui::test] +async fn test_autoclose_with_embedded_language(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + let html_language = Arc::new( + Language::new( + LanguageConfig { + name: "HTML".into(), + brackets: BracketPairConfig { + pairs: vec![ + BracketPair { + start: "<".into(), + end: ">".into(), + close: true, + ..Default::default() + }, + BracketPair { + start: "{".into(), + end: "}".into(), + close: true, + ..Default::default() + }, + BracketPair { + start: "(".into(), + end: ")".into(), + close: true, + ..Default::default() + }, + ], + ..Default::default() + }, + autoclose_before: "})]>".into(), + ..Default::default() + }, + Some(tree_sitter_html::LANGUAGE.into()), + ) + .with_injection_query( + r#" + (script_element + (raw_text) @injection.content + (#set! injection.language "javascript")) + "#, + ) + .unwrap(), + ); + + let javascript_language = Arc::new(Language::new( + LanguageConfig { + name: "JavaScript".into(), + brackets: BracketPairConfig { + pairs: vec![ + BracketPair { + start: "/*".into(), + end: " */".into(), + close: true, + ..Default::default() + }, + BracketPair { + start: "{".into(), + end: "}".into(), + close: true, + ..Default::default() + }, + BracketPair { + start: "(".into(), + end: ")".into(), + close: true, + ..Default::default() + }, + ], + ..Default::default() + }, + autoclose_before: "})]>".into(), + ..Default::default() + }, + Some(tree_sitter_typescript::LANGUAGE_TSX.into()), + )); + + cx.language_registry().add(html_language.clone()); + cx.language_registry().add(javascript_language); + cx.executor().run_until_parked(); + + cx.update_buffer(|buffer, cx| { + buffer.set_language(Some(html_language), cx); + }); + + cx.set_state( + &r#" + ˇ + + ˇ + "# + .unindent(), + ); + + // Precondition: different languages are active at different locations. + cx.update_editor(|editor, window, cx| { + let snapshot = editor.snapshot(window, cx); + let cursors = editor + .selections + .ranges::(&editor.display_snapshot(cx)); + let languages = cursors + .iter() + .map(|c| snapshot.language_at(c.start).unwrap().name()) + .collect::>(); + assert_eq!( + languages, + &[ + LanguageName::from("HTML"), + LanguageName::from("JavaScript"), + LanguageName::from("HTML"), + ] + ); + }); + + // Angle brackets autoclose in HTML, but not JavaScript. + cx.update_editor(|editor, window, cx| { + editor.handle_input("<", window, cx); + editor.handle_input("a", window, cx); + }); + cx.assert_editor_state( + &r#" + + + + "# + .unindent(), + ); + + // Curly braces and parens autoclose in both HTML and JavaScript. + cx.update_editor(|editor, window, cx| { + editor.handle_input(" b=", window, cx); + editor.handle_input("{", window, cx); + editor.handle_input("c", window, cx); + editor.handle_input("(", window, cx); + }); + cx.assert_editor_state( + &r#" + + + + "# + .unindent(), + ); + + // Brackets that were already autoclosed are skipped. + cx.update_editor(|editor, window, cx| { + editor.handle_input(")", window, cx); + editor.handle_input("d", window, cx); + editor.handle_input("}", window, cx); + }); + cx.assert_editor_state( + &r#" + + + + "# + .unindent(), + ); + cx.update_editor(|editor, window, cx| { + editor.handle_input(">", window, cx); + }); + cx.assert_editor_state( + &r#" + ˇ + + ˇ + "# + .unindent(), + ); + + // Reset + cx.set_state( + &r#" + ˇ + + ˇ + "# + .unindent(), + ); + + cx.update_editor(|editor, window, cx| { + editor.handle_input("<", window, cx); + }); + cx.assert_editor_state( + &r#" + <ˇ> + + <ˇ> + "# + .unindent(), + ); + + // When backspacing, the closing angle brackets are removed. + cx.update_editor(|editor, window, cx| { + editor.backspace(&Backspace, window, cx); + }); + cx.assert_editor_state( + &r#" + ˇ + + ˇ + "# + .unindent(), + ); + + // Block comments autoclose in JavaScript, but not HTML. + cx.update_editor(|editor, window, cx| { + editor.handle_input("/", window, cx); + editor.handle_input("*", window, cx); + }); + cx.assert_editor_state( + &r#" + /*ˇ + + /*ˇ + "# + .unindent(), + ); +} + +#[gpui::test] +async fn test_autoclose_with_overrides(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + let rust_language = Arc::new( + Language::new( + LanguageConfig { + name: "Rust".into(), + brackets: serde_json::from_value(json!([ + { "start": "{", "end": "}", "close": true, "newline": true }, + { "start": "\"", "end": "\"", "close": true, "newline": false, "not_in": ["string"] }, + ])) + .unwrap(), + autoclose_before: "})]>".into(), + ..Default::default() + }, + Some(tree_sitter_rust::LANGUAGE.into()), + ) + .with_override_query("(string_literal) @string") + .unwrap(), + ); + + cx.language_registry().add(rust_language.clone()); + cx.update_buffer(|buffer, cx| { + buffer.set_language(Some(rust_language), cx); + }); + + cx.set_state( + &r#" + let x = ˇ + "# + .unindent(), + ); + + // Inserting a quotation mark. A closing quotation mark is automatically inserted. + cx.update_editor(|editor, window, cx| { + editor.handle_input("\"", window, cx); + }); + cx.assert_editor_state( + &r#" + let x = "ˇ" + "# + .unindent(), + ); + + // Inserting another quotation mark. The cursor moves across the existing + // automatically-inserted quotation mark. + cx.update_editor(|editor, window, cx| { + editor.handle_input("\"", window, cx); + }); + cx.assert_editor_state( + &r#" + let x = ""ˇ + "# + .unindent(), + ); + + // Reset + cx.set_state( + &r#" + let x = ˇ + "# + .unindent(), + ); + + // Inserting a quotation mark inside of a string. A second quotation mark is not inserted. + cx.update_editor(|editor, window, cx| { + editor.handle_input("\"", window, cx); + editor.handle_input(" ", window, cx); + editor.move_left(&Default::default(), window, cx); + editor.handle_input("\\", window, cx); + editor.handle_input("\"", window, cx); + }); + cx.assert_editor_state( + &r#" + let x = "\"ˇ " + "# + .unindent(), + ); + + // Inserting a closing quotation mark at the position of an automatically-inserted quotation + // mark. Nothing is inserted. + cx.update_editor(|editor, window, cx| { + editor.move_right(&Default::default(), window, cx); + editor.handle_input("\"", window, cx); + }); + cx.assert_editor_state( + &r#" + let x = "\" "ˇ + "# + .unindent(), + ); +} + +#[gpui::test] +async fn test_autoclose_quotes_with_scope_awareness(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + let language = languages::language("python", tree_sitter_python::LANGUAGE.into()); + + cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); + + // Double quote inside single-quoted string + cx.set_state(indoc! {r#" + def main(): + items = ['"', ˇ] + "#}); + cx.update_editor(|editor, window, cx| { + editor.handle_input("\"", window, cx); + }); + cx.assert_editor_state(indoc! {r#" + def main(): + items = ['"', "ˇ"] + "#}); + + // Two double quotes inside single-quoted string + cx.set_state(indoc! {r#" + def main(): + items = ['""', ˇ] + "#}); + cx.update_editor(|editor, window, cx| { + editor.handle_input("\"", window, cx); + }); + cx.assert_editor_state(indoc! {r#" + def main(): + items = ['""', "ˇ"] + "#}); + + // Single quote inside double-quoted string + cx.set_state(indoc! {r#" + def main(): + items = ["'", ˇ] + "#}); + cx.update_editor(|editor, window, cx| { + editor.handle_input("'", window, cx); + }); + cx.assert_editor_state(indoc! {r#" + def main(): + items = ["'", 'ˇ'] + "#}); + + // Two single quotes inside double-quoted string + cx.set_state(indoc! {r#" + def main(): + items = ["''", ˇ] + "#}); + cx.update_editor(|editor, window, cx| { + editor.handle_input("'", window, cx); + }); + cx.assert_editor_state(indoc! {r#" + def main(): + items = ["''", 'ˇ'] + "#}); + + // Mixed quotes on same line + cx.set_state(indoc! {r#" + def main(): + items = ['"""', "'''''", ˇ] + "#}); + cx.update_editor(|editor, window, cx| { + editor.handle_input("\"", window, cx); + }); + cx.assert_editor_state(indoc! {r#" + def main(): + items = ['"""', "'''''", "ˇ"] + "#}); + cx.update_editor(|editor, window, cx| { + editor.move_right(&MoveRight, window, cx); + }); + cx.update_editor(|editor, window, cx| { + editor.handle_input(", ", window, cx); + }); + cx.update_editor(|editor, window, cx| { + editor.handle_input("'", window, cx); + }); + cx.assert_editor_state(indoc! {r#" + def main(): + items = ['"""', "'''''", "", 'ˇ'] + "#}); +} + +#[gpui::test] +async fn test_autoclose_quotes_with_multibyte_characters(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + let language = languages::language("python", tree_sitter_python::LANGUAGE.into()); + cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); + + cx.set_state(indoc! {r#" + def main(): + items = ["🎉", ˇ] + "#}); + cx.update_editor(|editor, window, cx| { + editor.handle_input("\"", window, cx); + }); + cx.assert_editor_state(indoc! {r#" + def main(): + items = ["🎉", "ˇ"] + "#}); +} + +#[gpui::test] +async fn test_surround_backticks_in_rust(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + cx.update_buffer(|buffer, cx| { + let language = languages::language("rust", tree_sitter_rust::LANGUAGE.into()); + buffer.set_language(Some(language), cx) + }); + + // Surround a selection inside a doc comment with backticks + cx.set_state(indoc! {" + /// «Aˇ» + fn main() {} + "}); + cx.update_editor(|editor, window, cx| { + editor.handle_input("`", window, cx); + }); + cx.assert_editor_state(indoc! {" + /// `«Aˇ»` + fn main() {} + "}); + + // When inside a string literal, the backtick pair is disabled so typing a + // backtick should replace the selection instead of surrounding it. + cx.set_state(indoc! {r#" + fn main() { + let name = "«Jesper Kouthoofdˇ»"; + } + "#}); + cx.update_editor(|editor, window, cx| { + editor.handle_input("`", window, cx); + }); + cx.assert_editor_state(indoc! {r#" + fn main() { + let name = "`ˇ"; + } + "#}); +} + +#[gpui::test] +async fn test_surround_with_pair(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let language = Arc::new(Language::new( + LanguageConfig { + brackets: BracketPairConfig { + pairs: vec![ + BracketPair { + start: "{".to_string(), + end: "}".to_string(), + close: true, + surround: true, + newline: true, + }, + BracketPair { + start: "/* ".to_string(), + end: "*/".to_string(), + close: true, + surround: true, + ..Default::default() + }, + ], + ..Default::default() + }, + ..Default::default() + }, + Some(tree_sitter_rust::LANGUAGE.into()), + )); + + let text = r#" + a + b + c + "# + .unindent(); + + let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(language, cx)); + let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + let (editor, cx) = cx.add_window_view(|window, cx| build_editor(buffer, window, cx)); + editor + .condition::(cx, |editor, cx| !editor.buffer.read(cx).is_parsing(cx)) + .await; + + editor.update_in(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 1), + DisplayPoint::new(DisplayRow(1), 0)..DisplayPoint::new(DisplayRow(1), 1), + DisplayPoint::new(DisplayRow(2), 0)..DisplayPoint::new(DisplayRow(2), 1), + ]) + }); + + editor.handle_input("{", window, cx); + editor.handle_input("{", window, cx); + editor.handle_input("{", window, cx); + assert_eq!( + editor.text(cx), + " + {{{a}}} + {{{b}}} + {{{c}}} + " + .unindent() + ); + assert_eq!( + display_ranges(editor, cx), + [ + DisplayPoint::new(DisplayRow(0), 3)..DisplayPoint::new(DisplayRow(0), 4), + DisplayPoint::new(DisplayRow(1), 3)..DisplayPoint::new(DisplayRow(1), 4), + DisplayPoint::new(DisplayRow(2), 3)..DisplayPoint::new(DisplayRow(2), 4) + ] + ); + + editor.undo(&Undo, window, cx); + editor.undo(&Undo, window, cx); + editor.undo(&Undo, window, cx); + assert_eq!( + editor.text(cx), + " + a + b + c + " + .unindent() + ); + assert_eq!( + display_ranges(editor, cx), + [ + DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 1), + DisplayPoint::new(DisplayRow(1), 0)..DisplayPoint::new(DisplayRow(1), 1), + DisplayPoint::new(DisplayRow(2), 0)..DisplayPoint::new(DisplayRow(2), 1) + ] + ); + + // Ensure inserting the first character of a multi-byte bracket pair + // doesn't surround the selections with the bracket. + editor.handle_input("/", window, cx); + assert_eq!( + editor.text(cx), + " + / + / + / + " + .unindent() + ); + assert_eq!( + display_ranges(editor, cx), + [ + DisplayPoint::new(DisplayRow(0), 1)..DisplayPoint::new(DisplayRow(0), 1), + DisplayPoint::new(DisplayRow(1), 1)..DisplayPoint::new(DisplayRow(1), 1), + DisplayPoint::new(DisplayRow(2), 1)..DisplayPoint::new(DisplayRow(2), 1) + ] + ); + + editor.undo(&Undo, window, cx); + assert_eq!( + editor.text(cx), + " + a + b + c + " + .unindent() + ); + assert_eq!( + display_ranges(editor, cx), + [ + DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 1), + DisplayPoint::new(DisplayRow(1), 0)..DisplayPoint::new(DisplayRow(1), 1), + DisplayPoint::new(DisplayRow(2), 0)..DisplayPoint::new(DisplayRow(2), 1) + ] + ); + + // Ensure inserting the last character of a multi-byte bracket pair + // doesn't surround the selections with the bracket. + editor.handle_input("*", window, cx); + assert_eq!( + editor.text(cx), + " + * + * + * + " + .unindent() + ); + assert_eq!( + display_ranges(editor, cx), + [ + DisplayPoint::new(DisplayRow(0), 1)..DisplayPoint::new(DisplayRow(0), 1), + DisplayPoint::new(DisplayRow(1), 1)..DisplayPoint::new(DisplayRow(1), 1), + DisplayPoint::new(DisplayRow(2), 1)..DisplayPoint::new(DisplayRow(2), 1) + ] + ); + }); +} + +#[gpui::test] +async fn test_delete_autoclose_pair(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let language = Arc::new(Language::new( + LanguageConfig { + brackets: BracketPairConfig { + pairs: vec![BracketPair { + start: "{".to_string(), + end: "}".to_string(), + close: true, + surround: true, + newline: true, + }], + ..Default::default() + }, + autoclose_before: "}".to_string(), + ..Default::default() + }, + Some(tree_sitter_rust::LANGUAGE.into()), + )); + + let text = r#" + a + b + c + "# + .unindent(); + + let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(language, cx)); + let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + let (editor, cx) = cx.add_window_view(|window, cx| build_editor(buffer, window, cx)); + editor + .condition::(cx, |editor, cx| !editor.buffer.read(cx).is_parsing(cx)) + .await; + + editor.update_in(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_ranges([ + Point::new(0, 1)..Point::new(0, 1), + Point::new(1, 1)..Point::new(1, 1), + Point::new(2, 1)..Point::new(2, 1), + ]) + }); + + editor.handle_input("{", window, cx); + editor.handle_input("{", window, cx); + editor.handle_input("_", window, cx); + assert_eq!( + editor.text(cx), + " + a{{_}} + b{{_}} + c{{_}} + " + .unindent() + ); + assert_eq!( + editor + .selections + .ranges::(&editor.display_snapshot(cx)), + [ + Point::new(0, 4)..Point::new(0, 4), + Point::new(1, 4)..Point::new(1, 4), + Point::new(2, 4)..Point::new(2, 4) + ] + ); + + editor.backspace(&Default::default(), window, cx); + editor.backspace(&Default::default(), window, cx); + assert_eq!( + editor.text(cx), + " + a{} + b{} + c{} + " + .unindent() + ); + assert_eq!( + editor + .selections + .ranges::(&editor.display_snapshot(cx)), + [ + Point::new(0, 2)..Point::new(0, 2), + Point::new(1, 2)..Point::new(1, 2), + Point::new(2, 2)..Point::new(2, 2) + ] + ); + + editor.delete_to_previous_word_start(&Default::default(), window, cx); + assert_eq!( + editor.text(cx), + " + a + b + c + " + .unindent() + ); + assert_eq!( + editor + .selections + .ranges::(&editor.display_snapshot(cx)), + [ + Point::new(0, 1)..Point::new(0, 1), + Point::new(1, 1)..Point::new(1, 1), + Point::new(2, 1)..Point::new(2, 1) + ] + ); + }); +} + +#[gpui::test] +async fn test_always_treat_brackets_as_autoclosed_delete(cx: &mut TestAppContext) { + init_test(cx, |settings| { + settings.defaults.always_treat_brackets_as_autoclosed = Some(true); + }); + + let mut cx = EditorTestContext::new(cx).await; + + let language = Arc::new(Language::new( + LanguageConfig { + brackets: BracketPairConfig { + pairs: vec![ + BracketPair { + start: "{".to_string(), + end: "}".to_string(), + close: true, + surround: true, + newline: true, + }, + BracketPair { + start: "(".to_string(), + end: ")".to_string(), + close: true, + surround: true, + newline: true, + }, + BracketPair { + start: "[".to_string(), + end: "]".to_string(), + close: false, + surround: true, + newline: true, + }, + ], + ..Default::default() + }, + autoclose_before: "})]".to_string(), + ..Default::default() + }, + Some(tree_sitter_rust::LANGUAGE.into()), + )); + + cx.language_registry().add(language.clone()); + cx.update_buffer(|buffer, cx| { + buffer.set_language(Some(language), cx); + }); + + cx.set_state( + &" + {(ˇ)} + [[ˇ]] + {(ˇ)} + " + .unindent(), + ); + + cx.update_editor(|editor, window, cx| { + editor.backspace(&Default::default(), window, cx); + editor.backspace(&Default::default(), window, cx); + }); + + cx.assert_editor_state( + &" + ˇ + ˇ]] + ˇ + " + .unindent(), + ); + + cx.update_editor(|editor, window, cx| { + editor.handle_input("{", window, cx); + editor.handle_input("{", window, cx); + editor.move_right(&MoveRight, window, cx); + editor.move_right(&MoveRight, window, cx); + editor.move_left(&MoveLeft, window, cx); + editor.move_left(&MoveLeft, window, cx); + editor.backspace(&Default::default(), window, cx); + }); + + cx.assert_editor_state( + &" + {ˇ} + {ˇ}]] + {ˇ} + " + .unindent(), + ); + + cx.update_editor(|editor, window, cx| { + editor.backspace(&Default::default(), window, cx); + }); + + cx.assert_editor_state( + &" + ˇ + ˇ]] + ˇ + " + .unindent(), + ); +} + +#[gpui::test] +async fn test_auto_replace_emoji_shortcode(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let language = Arc::new(Language::new( + LanguageConfig::default(), + Some(tree_sitter_rust::LANGUAGE.into()), + )); + + let buffer = cx.new(|cx| Buffer::local("", cx).with_language(language, cx)); + let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + let (editor, cx) = cx.add_window_view(|window, cx| build_editor(buffer, window, cx)); + editor + .condition::(cx, |editor, cx| !editor.buffer.read(cx).is_parsing(cx)) + .await; + + editor.update_in(cx, |editor, window, cx| { + editor.set_auto_replace_emoji_shortcode(true); + + editor.handle_input("Hello ", window, cx); + editor.handle_input(":wave", window, cx); + assert_eq!(editor.text(cx), "Hello :wave".unindent()); + + editor.handle_input(":", window, cx); + assert_eq!(editor.text(cx), "Hello 👋".unindent()); + + editor.handle_input(" :smile", window, cx); + assert_eq!(editor.text(cx), "Hello 👋 :smile".unindent()); + + editor.handle_input(":", window, cx); + assert_eq!(editor.text(cx), "Hello 👋 😄".unindent()); + + // Ensure shortcode gets replaced when it is part of a word that only consists of emojis + editor.handle_input(":wave", window, cx); + assert_eq!(editor.text(cx), "Hello 👋 😄:wave".unindent()); + + editor.handle_input(":", window, cx); + assert_eq!(editor.text(cx), "Hello 👋 😄👋".unindent()); + + editor.handle_input(":1", window, cx); + assert_eq!(editor.text(cx), "Hello 👋 😄👋:1".unindent()); + + editor.handle_input(":", window, cx); + assert_eq!(editor.text(cx), "Hello 👋 😄👋:1:".unindent()); + + // Ensure shortcode does not get replaced when it is part of a word + editor.handle_input(" Test:wave", window, cx); + assert_eq!(editor.text(cx), "Hello 👋 😄👋:1: Test:wave".unindent()); + + editor.handle_input(":", window, cx); + assert_eq!(editor.text(cx), "Hello 👋 😄👋:1: Test:wave:".unindent()); + + editor.set_auto_replace_emoji_shortcode(false); + + // Ensure shortcode does not get replaced when auto replace is off + editor.handle_input(" :wave", window, cx); + assert_eq!( + editor.text(cx), + "Hello 👋 😄👋:1: Test:wave: :wave".unindent() + ); + + editor.handle_input(":", window, cx); + assert_eq!( + editor.text(cx), + "Hello 👋 😄👋:1: Test:wave: :wave:".unindent() + ); + }); +} + +#[gpui::test] +async fn test_snippet_placeholder_choices(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let (text, insertion_ranges) = marked_text_ranges( + indoc! {" + ˇ + "}, + false, + ); + + let buffer = cx.update(|cx| MultiBuffer::build_simple(&text, cx)); + let (editor, cx) = cx.add_window_view(|window, cx| build_editor(buffer, window, cx)); + + _ = editor.update_in(cx, |editor, window, cx| { + let snippet = Snippet::parse("type ${1|,i32,u32|} = $2").unwrap(); + + editor + .insert_snippet( + &insertion_ranges + .iter() + .map(|range| MultiBufferOffset(range.start)..MultiBufferOffset(range.end)) + .collect::>(), + snippet, + window, + cx, + ) + .unwrap(); + + fn assert(editor: &mut Editor, cx: &mut Context, marked_text: &str) { + let (expected_text, selection_ranges) = marked_text_ranges(marked_text, false); + assert_eq!(editor.text(cx), expected_text); + assert_eq!( + editor.selections.ranges(&editor.display_snapshot(cx)), + selection_ranges + .iter() + .map(|range| MultiBufferOffset(range.start)..MultiBufferOffset(range.end)) + .collect::>() + ); + } + + assert( + editor, + cx, + indoc! {" + type «» =• + "}, + ); + + assert!(editor.context_menu_visible(), "There should be a matches"); + }); +} + +#[gpui::test] +async fn test_snippet_choices_menu_survives_completion_refresh(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let (text, insertion_ranges) = marked_text_ranges( + indoc! {" + ˇ + "}, + false, + ); + + let buffer = cx.update(|cx| MultiBuffer::build_simple(&text, cx)); + let (editor, cx) = cx.add_window_view(|window, cx| build_editor(buffer, window, cx)); + + _ = editor.update_in(cx, |editor, window, cx| { + let snippet = Snippet::parse("type ${1|i32,u32|} = $2").unwrap(); + + editor + .insert_snippet( + &insertion_ranges + .iter() + .map(|range| MultiBufferOffset(range.start)..MultiBufferOffset(range.end)) + .collect::>(), + snippet, + window, + cx, + ) + .unwrap(); + + assert!( + editor.context_menu_visible(), + "Snippet choices menu should be visible after inserting the choice tabstop" + ); + + editor.open_or_update_completions_menu(None, None, false, window, cx); + + assert!( + editor.context_menu_visible(), + "Snippet choices menu should remain visible after a completion refresh with an empty query" + ); + }); +} + +#[gpui::test] +async fn test_snippet_tabstop_navigation_with_placeholders(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + fn assert_state(editor: &mut Editor, cx: &mut Context, marked_text: &str) { + let (expected_text, selection_ranges) = marked_text_ranges(marked_text, false); + assert_eq!(editor.text(cx), expected_text); + assert_eq!( + editor.selections.ranges(&editor.display_snapshot(cx)), + selection_ranges + .iter() + .map(|range| MultiBufferOffset(range.start)..MultiBufferOffset(range.end)) + .collect::>() + ); + } + + let (text, insertion_ranges) = marked_text_ranges( + indoc! {" + ˇ + "}, + false, + ); + + let buffer = cx.update(|cx| MultiBuffer::build_simple(&text, cx)); + let (editor, cx) = cx.add_window_view(|window, cx| build_editor(buffer, window, cx)); + + _ = editor.update_in(cx, |editor, window, cx| { + let snippet = Snippet::parse("type ${1|,i32,u32|} = $2; $3").unwrap(); + + editor + .insert_snippet( + &insertion_ranges + .iter() + .map(|range| MultiBufferOffset(range.start)..MultiBufferOffset(range.end)) + .collect::>(), + snippet, + window, + cx, + ) + .unwrap(); + + assert_state( + editor, + cx, + indoc! {" + type «» = ;• + "}, + ); + + assert!( + editor.context_menu_visible(), + "Context menu should be visible for placeholder choices" + ); + + editor.next_snippet_tabstop(&NextSnippetTabstop, window, cx); + + assert_state( + editor, + cx, + indoc! {" + type = «»;• + "}, + ); + + assert!( + !editor.context_menu_visible(), + "Context menu should be hidden after moving to next tabstop" + ); + + editor.next_snippet_tabstop(&NextSnippetTabstop, window, cx); + + assert_state( + editor, + cx, + indoc! {" + type = ; ˇ + "}, + ); + + editor.next_snippet_tabstop(&NextSnippetTabstop, window, cx); + + assert_state( + editor, + cx, + indoc! {" + type = ; ˇ + "}, + ); + }); + + _ = editor.update_in(cx, |editor, window, cx| { + editor.select_all(&SelectAll, window, cx); + editor.backspace(&Backspace, window, cx); + + let snippet = Snippet::parse("fn ${1|,foo,bar|} = ${2:value}; $3").unwrap(); + let insertion_ranges = editor + .selections + .all(&editor.display_snapshot(cx)) + .iter() + .map(|s| s.range()) + .collect::>(); + + editor + .insert_snippet(&insertion_ranges, snippet, window, cx) + .unwrap(); + + assert_state(editor, cx, "fn «» = value;•"); + + assert!( + editor.context_menu_visible(), + "Context menu should be visible for placeholder choices" + ); + + editor.next_snippet_tabstop(&NextSnippetTabstop, window, cx); + + assert_state(editor, cx, "fn = «valueˇ»;•"); + + editor.previous_snippet_tabstop(&PreviousSnippetTabstop, window, cx); + + assert_state(editor, cx, "fn «» = value;•"); + + assert!( + editor.context_menu_visible(), + "Context menu should be visible again after returning to first tabstop" + ); + + editor.previous_snippet_tabstop(&PreviousSnippetTabstop, window, cx); + + assert_state(editor, cx, "fn «» = value;•"); + }); +} + +#[gpui::test] +async fn test_snippets(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + cx.set_state(indoc! {" + a.ˇ b + a.ˇ b + a.ˇ b + "}); + + cx.update_editor(|editor, window, cx| { + let snippet = Snippet::parse("f(${1:one}, ${2:two}, ${1:three})$0").unwrap(); + let insertion_ranges = editor + .selections + .all(&editor.display_snapshot(cx)) + .iter() + .map(|s| s.range()) + .collect::>(); + editor + .insert_snippet(&insertion_ranges, snippet, window, cx) + .unwrap(); + }); + + cx.assert_editor_state(indoc! {" + a.f(«oneˇ», two, «threeˇ») b + a.f(«oneˇ», two, «threeˇ») b + a.f(«oneˇ», two, «threeˇ») b + "}); + + // Can't move earlier than the first tab stop + cx.update_editor(|editor, window, cx| { + assert!(!editor.move_to_prev_snippet_tabstop(window, cx)) + }); + cx.assert_editor_state(indoc! {" + a.f(«oneˇ», two, «threeˇ») b + a.f(«oneˇ», two, «threeˇ») b + a.f(«oneˇ», two, «threeˇ») b + "}); + + cx.update_editor(|editor, window, cx| assert!(editor.move_to_next_snippet_tabstop(window, cx))); + cx.assert_editor_state(indoc! {" + a.f(one, «twoˇ», three) b + a.f(one, «twoˇ», three) b + a.f(one, «twoˇ», three) b + "}); + + cx.update_editor(|editor, window, cx| assert!(editor.move_to_prev_snippet_tabstop(window, cx))); + cx.assert_editor_state(indoc! {" + a.f(«oneˇ», two, «threeˇ») b + a.f(«oneˇ», two, «threeˇ») b + a.f(«oneˇ», two, «threeˇ») b + "}); + + cx.update_editor(|editor, window, cx| assert!(editor.move_to_next_snippet_tabstop(window, cx))); + cx.assert_editor_state(indoc! {" + a.f(one, «twoˇ», three) b + a.f(one, «twoˇ», three) b + a.f(one, «twoˇ», three) b + "}); + cx.update_editor(|editor, window, cx| assert!(editor.move_to_next_snippet_tabstop(window, cx))); + cx.assert_editor_state(indoc! {" + a.f(one, two, three)ˇ b + a.f(one, two, three)ˇ b + a.f(one, two, three)ˇ b + "}); + + // As soon as the last tab stop is reached, snippet state is gone + cx.update_editor(|editor, window, cx| { + assert!(!editor.move_to_prev_snippet_tabstop(window, cx)) + }); + cx.assert_editor_state(indoc! {" + a.f(one, two, three)ˇ b + a.f(one, two, three)ˇ b + a.f(one, two, three)ˇ b + "}); +} + +#[gpui::test] +async fn test_snippet_indentation(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + cx.update_editor(|editor, window, cx| { + let snippet = Snippet::parse(indoc! {" + /* + * Multiline comment with leading indentation + * + * $1 + */ + $0"}) + .unwrap(); + let insertion_ranges = editor + .selections + .all(&editor.display_snapshot(cx)) + .iter() + .map(|s| s.range()) + .collect::>(); + editor + .insert_snippet(&insertion_ranges, snippet, window, cx) + .unwrap(); + }); + + cx.assert_editor_state(indoc! {" + /* + * Multiline comment with leading indentation + * + * ˇ + */ + "}); + + cx.update_editor(|editor, window, cx| assert!(editor.move_to_next_snippet_tabstop(window, cx))); + cx.assert_editor_state(indoc! {" + /* + * Multiline comment with leading indentation + * + *• + */ + ˇ"}); +} + +#[gpui::test] +async fn test_snippet_with_multi_word_prefix(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + cx.update_editor(|editor, _, cx| { + editor.project().unwrap().update(cx, |project, cx| { + project.snippets().update(cx, |snippets, _cx| { + let snippet = project::snippet_provider::Snippet { + prefix: vec!["multi word".to_string()], + body: "this is many words".to_string(), + description: Some("description".to_string()), + name: "multi-word snippet test".to_string(), + }; + snippets.add_snippet_for_test( + None, + PathBuf::from("test_snippets.json"), + vec![Arc::new(snippet)], + ); + }); + }) + }); + + for (input_to_simulate, should_match_snippet) in [ + ("m", true), + ("m ", true), + ("m w", true), + ("aa m w", true), + ("aa m g", false), + ] { + cx.set_state("ˇ"); + cx.simulate_input(input_to_simulate); // fails correctly + + cx.update_editor(|editor, _, _| { + let Some(CodeContextMenu::Completions(context_menu)) = &*editor.context_menu.borrow() + else { + assert!(!should_match_snippet); // no completions! don't even show the menu + return; + }; + assert!(context_menu.visible()); + let completions = context_menu.completions.borrow(); + + assert_eq!(!completions.is_empty(), should_match_snippet); + }); + } +} + +#[gpui::test] +async fn test_read_only_buffer_is_not_formatted_or_saved(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let fs = FakeFs::new(cx.executor()); + fs.insert_file(path!("/file.rs"), b"one\ntwo\nthree\n".to_vec()) + .await; + + let project = Project::test(fs.clone(), [path!("/file.rs").as_ref()], cx).await; + + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(rust_lang()); + let format_request_count = Arc::new(AtomicUsize::new(0)); + let mut fake_servers = language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + capabilities: lsp::ServerCapabilities { + document_formatting_provider: Some(lsp::OneOf::Left(true)), + ..lsp::ServerCapabilities::default() + }, + initializer: Some(Box::new({ + let format_request_count = format_request_count.clone(); + move |fake_server| { + let format_request_count = format_request_count.clone(); + fake_server.set_request_handler::( + move |_, _| { + let format_request_count = format_request_count.clone(); + async move { + format_request_count.fetch_add(1, atomic::Ordering::Release); + Ok(Some(vec![lsp::TextEdit::new( + lsp::Range::new( + lsp::Position::new(0, 3), + lsp::Position::new(1, 0), + ), + ", ".to_string(), + )])) + } + }, + ); + } + })), + ..FakeLspAdapter::default() + }, + ); + + let buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/file.rs"), cx) + }) + .await + .unwrap(); + buffer.update(cx, |buffer, cx| buffer.set_capability(Read, cx)); + + let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + let (editor, cx) = cx.add_window_view(|window, cx| { + build_editor_with_project(project.clone(), buffer, window, cx) + }); + + fake_servers.next().await.unwrap(); + + let save = editor + .update_in(cx, |editor, window, cx| { + editor.save( + SaveOptions { + format: true, + force_format: false, + autosave: false, + }, + project.clone(), + window, + cx, + ) + }) + .unwrap(); + save.await; + + assert_eq!(format_request_count.load(atomic::Ordering::Acquire), 0); + assert_eq!( + editor.update(cx, |editor, cx| editor.text(cx)), + "one\ntwo\nthree\n" + ); + assert_eq!( + fs.load(path!("/file.rs").as_ref()).await.unwrap(), + "one\ntwo\nthree\n" + ); +} + +#[gpui::test] +async fn test_read_only_buffer_can_be_saved_as(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/dir"), + json!({ + "file.rs": "one\ntwo\nthree\n", + }), + ) + .await; + + let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; + let worktree_id = project.update(cx, |project, cx| { + project.worktrees(cx).next().unwrap().read(cx).id() + }); + + let buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/dir/file.rs"), cx) + }) + .await + .unwrap(); + buffer.update(cx, |buffer, cx| buffer.set_capability(Read, cx)); + + let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + let (editor, cx) = cx.add_window_view(|window, cx| { + build_editor_with_project(project.clone(), buffer, window, cx) + }); + + let save_as = editor.update_in(cx, |editor, window, cx| { + editor.save_as( + project.clone(), + ProjectPath { + worktree_id, + path: rel_path("copy.rs").into(), + }, + window, + cx, + ) + }); + save_as.await.unwrap(); + + assert_eq!( + fs.load(path!("/dir/copy.rs").as_ref()).await.unwrap(), + "one\ntwo\nthree\n" + ); + assert_eq!( + fs.load(path!("/dir/file.rs").as_ref()).await.unwrap(), + "one\ntwo\nthree\n" + ); +} + +#[gpui::test] +async fn test_format_and_save_skip_read_only_buffers_in_multi_buffer(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/a"), + json!({ + "main.rs": "one\ntwo\nthree\n", + "other.rs": "one\ntwo\nthree\n", + }), + ) + .await; + + let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await; + + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(rust_lang()); + let formatted_uris = Arc::new(Mutex::new(Vec::new())); + let mut fake_servers = language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + capabilities: lsp::ServerCapabilities { + document_formatting_provider: Some(lsp::OneOf::Left(true)), + ..lsp::ServerCapabilities::default() + }, + initializer: Some(Box::new({ + let formatted_uris = formatted_uris.clone(); + move |fake_server| { + let formatted_uris = formatted_uris.clone(); + fake_server.set_request_handler::( + move |params, _| { + let formatted_uris = formatted_uris.clone(); + async move { + formatted_uris.lock().push(params.text_document.uri.clone()); + Ok(Some(vec![lsp::TextEdit::new( + lsp::Range::new( + lsp::Position::new(0, 3), + lsp::Position::new(1, 0), + ), + ", ".to_string(), + )])) + } + }, + ); + } + })), + ..FakeLspAdapter::default() + }, + ); + + let writable_buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/a/main.rs"), cx) + }) + .await + .unwrap(); + let read_only_buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/a/other.rs"), cx) + }) + .await + .unwrap(); + read_only_buffer.update(cx, |buffer, cx| buffer.set_capability(Read, cx)); + + let multi_buffer = cx.new(|cx| { + let mut multi_buffer = MultiBuffer::new(ReadWrite); + multi_buffer.set_excerpts_for_path( + PathKey::sorted(0), + writable_buffer.clone(), + [Point::new(0, 0)..Point::new(3, 0)], + 0, + cx, + ); + multi_buffer.set_excerpts_for_path( + PathKey::sorted(1), + read_only_buffer.clone(), + [Point::new(0, 0)..Point::new(3, 0)], + 0, + cx, + ); + multi_buffer + }); + let (editor, cx) = cx.add_window_view(|window, cx| { + build_editor_with_project(project.clone(), multi_buffer, window, cx) + }); + + fake_servers.next().await.unwrap(); + + let format = editor + .update_in(cx, |editor, window, cx| editor.format(&Format, window, cx)) + .unwrap(); + format.await.unwrap(); + + assert_eq!( + *formatted_uris.lock(), + vec![lsp::Uri::from_file_path(path!("/a/main.rs")).unwrap()] + ); + writable_buffer.read_with(cx, |buffer, _| { + assert_eq!(buffer.text(), "one, two\nthree\n"); + }); + read_only_buffer.read_with(cx, |buffer, _| { + assert_eq!(buffer.text(), "one\ntwo\nthree\n"); + }); + + read_only_buffer.update(cx, |buffer, cx| { + buffer.edit([(0..0, "dirty ")], None, cx); + }); + assert_eq!( + read_only_buffer.read_with(cx, |buffer, _| buffer.is_dirty()), + true + ); + + let save = editor + .update_in(cx, |editor, window, cx| { + editor.save( + SaveOptions { + format: false, + force_format: false, + autosave: false, + }, + project.clone(), + window, + cx, + ) + }) + .unwrap(); + save.await; + + assert_eq!( + fs.load(path!("/a/main.rs").as_ref()).await.unwrap(), + "one, two\nthree\n" + ); + assert_eq!( + fs.load(path!("/a/other.rs").as_ref()).await.unwrap(), + "one\ntwo\nthree\n" + ); +} + +#[gpui::test] +async fn test_save_actions_are_hidden_for_read_only_files(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let fs = FakeFs::new(cx.executor()); + fs.insert_file(path!("/file.rs"), b"one\ntwo\nthree\n".to_vec()) + .await; + let project = Project::test(fs, [path!("/file.rs").as_ref()], cx).await; + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(*window, cx); + + let buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/file.rs"), cx) + }) + .await + .unwrap(); + let multi_buffer = cx.new(|cx| MultiBuffer::singleton(buffer.clone(), cx)); + let editor = cx.new_window_entity(|window, cx| { + Editor::new( + EditorMode::full(), + multi_buffer, + Some(project.clone()), + window, + cx, + ) + }); + window + .update(cx, |multi_workspace, window, cx| { + multi_workspace.workspace().update(cx, |workspace, cx| { + workspace.add_item_to_active_pane(Box::new(editor.clone()), None, true, window, cx); + }) + }) + .unwrap(); + cx.run_until_parked(); + + fn save_actions_visible(cx: &mut VisualTestContext) -> (bool, bool, bool, bool) { + let actions = cx.update(|window, cx| window.available_actions(cx)); + let visible = |name: &str| actions.iter().any(|action| action.name() == name); + ( + visible("workspace::Save"), + visible("workspace::FormatAndSave"), + visible("workspace::SaveWithoutFormat"), + visible("workspace::SaveAs"), + ) + } + + assert_eq!(save_actions_visible(cx), (true, true, true, true)); + + buffer.update(cx, |buffer, cx| buffer.set_capability(ReadOnly, cx)); + cx.run_until_parked(); + assert_eq!(save_actions_visible(cx), (false, false, false, true)); + + buffer.update(cx, |buffer, cx| buffer.set_capability(ReadWrite, cx)); + cx.run_until_parked(); + assert_eq!(save_actions_visible(cx), (true, true, true, true)); +} + +#[gpui::test] +async fn test_document_format_during_save(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let fs = FakeFs::new(cx.executor()); + fs.insert_file(path!("/file.rs"), Default::default()).await; + + let project = Project::test(fs, [path!("/file.rs").as_ref()], cx).await; + + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(rust_lang()); + let mut fake_servers = language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + capabilities: lsp::ServerCapabilities { + document_formatting_provider: Some(lsp::OneOf::Left(true)), + ..Default::default() + }, + ..Default::default() + }, + ); + + let buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/file.rs"), cx) + }) + .await + .unwrap(); + + let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + let (editor, cx) = cx.add_window_view(|window, cx| { + build_editor_with_project(project.clone(), buffer, window, cx) + }); + editor.update_in(cx, |editor, window, cx| { + editor.set_text("one\ntwo\nthree\n", window, cx) + }); + assert!(cx.read(|cx| editor.is_dirty(cx))); + + let fake_server = fake_servers.next().await.unwrap(); + + { + fake_server.set_request_handler::( + move |params, _| async move { + assert_eq!( + params.text_document.uri, + lsp::Uri::from_file_path(path!("/file.rs")).unwrap() + ); + assert_eq!(params.options.tab_size, 4); + Ok(Some(vec![lsp::TextEdit::new( + lsp::Range::new(lsp::Position::new(0, 3), lsp::Position::new(1, 0)), + ", ".to_string(), + )])) + }, + ); + let save = editor + .update_in(cx, |editor, window, cx| { + editor.save( + SaveOptions { + format: true, + force_format: false, + autosave: false, + }, + project.clone(), + window, + cx, + ) + }) + .unwrap(); + save.await; + + assert_eq!( + editor.update(cx, |editor, cx| editor.text(cx)), + "one, two\nthree\n" + ); + assert!(!cx.read(|cx| editor.is_dirty(cx))); + } + + { + editor.update_in(cx, |editor, window, cx| { + editor.set_text("one\ntwo\nthree\n", window, cx) + }); + assert!(cx.read(|cx| editor.is_dirty(cx))); + + // Ensure we can still save even if formatting hangs. + fake_server.set_request_handler::( + move |params, _| async move { + assert_eq!( + params.text_document.uri, + lsp::Uri::from_file_path(path!("/file.rs")).unwrap() + ); + futures::future::pending::<()>().await; + unreachable!() + }, + ); + let save = editor + .update_in(cx, |editor, window, cx| { + editor.save( + SaveOptions { + format: true, + force_format: false, + autosave: false, + }, + project.clone(), + window, + cx, + ) + }) + .unwrap(); + cx.executor().advance_clock(super::FORMAT_TIMEOUT); + save.await; + assert_eq!( + editor.update(cx, |editor, cx| editor.text(cx)), + "one\ntwo\nthree\n" + ); + } + + // Set rust language override and assert overridden tabsize is sent to language server + update_test_language_settings(cx, &|settings| { + settings.languages.0.insert( + "Rust".into(), + LanguageSettingsContent { + tab_size: NonZeroU32::new(8), + ..Default::default() + }, + ); + }); + + { + editor.update_in(cx, |editor, window, cx| { + editor.set_text("somehting_new\n", window, cx) + }); + assert!(cx.read(|cx| editor.is_dirty(cx))); + let _formatting_request_signal = fake_server + .set_request_handler::(move |params, _| async move { + assert_eq!( + params.text_document.uri, + lsp::Uri::from_file_path(path!("/file.rs")).unwrap() + ); + assert_eq!(params.options.tab_size, 8); + Ok(Some(vec![])) + }); + let save = editor + .update_in(cx, |editor, window, cx| { + editor.save( + SaveOptions { + format: true, + force_format: false, + autosave: false, + }, + project.clone(), + window, + cx, + ) + }) + .unwrap(); + save.await; + } +} + +#[gpui::test] +async fn test_auto_formatter_skips_server_without_formatting(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let fs = FakeFs::new(cx.executor()); + fs.insert_file(path!("/file.rs"), Default::default()).await; + + let project = Project::test(fs, [path!("/file.rs").as_ref()], cx).await; + + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(rust_lang()); + + // First server: no formatting capability + let mut no_format_servers = language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + name: "no-format-server", + capabilities: lsp::ServerCapabilities { + completion_provider: Some(lsp::CompletionOptions::default()), + ..Default::default() + }, + ..Default::default() + }, + ); + + // Second server: has formatting capability + let mut format_servers = language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + name: "format-server", + capabilities: lsp::ServerCapabilities { + document_formatting_provider: Some(lsp::OneOf::Left(true)), + ..Default::default() + }, + ..Default::default() + }, + ); + + let buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/file.rs"), cx) + }) + .await + .unwrap(); + + let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + let (editor, cx) = cx.add_window_view(|window, cx| { + build_editor_with_project(project.clone(), buffer, window, cx) + }); + editor.update_in(cx, |editor, window, cx| { + editor.set_text("one\ntwo\nthree\n", window, cx) + }); + + let _no_format_server = no_format_servers.next().await.unwrap(); + let format_server = format_servers.next().await.unwrap(); + + format_server.set_request_handler::( + move |params, _| async move { + assert_eq!( + params.text_document.uri, + lsp::Uri::from_file_path(path!("/file.rs")).unwrap() + ); + Ok(Some(vec![lsp::TextEdit::new( + lsp::Range::new(lsp::Position::new(0, 3), lsp::Position::new(1, 0)), + ", ".to_string(), + )])) + }, + ); + + let save = editor + .update_in(cx, |editor, window, cx| { + editor.save( + SaveOptions { + format: true, + force_format: false, + autosave: false, + }, + project.clone(), + window, + cx, + ) + }) + .unwrap(); + save.await; + + assert_eq!( + editor.update(cx, |editor, cx| editor.text(cx)), + "one, two\nthree\n" + ); +} + +#[gpui::test] +async fn test_redo_after_noop_format(cx: &mut TestAppContext) { + init_test(cx, |settings| { + settings.defaults.ensure_final_newline_on_save = Some(false); + }); + + let fs = FakeFs::new(cx.executor()); + fs.insert_file(path!("/file.txt"), "foo".into()).await; + + let project = Project::test(fs, [path!("/file.txt").as_ref()], cx).await; + + let buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/file.txt"), cx) + }) + .await + .unwrap(); + + let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + let (editor, cx) = cx.add_window_view(|window, cx| { + build_editor_with_project(project.clone(), buffer, window, cx) + }); + editor.update_in(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::default(), window, cx, |s| { + s.select_ranges([MultiBufferOffset(0)..MultiBufferOffset(0)]) + }); + }); + assert!(!cx.read(|cx| editor.is_dirty(cx))); + + editor.update_in(cx, |editor, window, cx| { + editor.handle_input("\n", window, cx) + }); + cx.run_until_parked(); + save(&editor, &project, cx).await; + assert_eq!("\nfoo", editor.read_with(cx, |editor, cx| editor.text(cx))); + + editor.update_in(cx, |editor, window, cx| { + editor.undo(&Default::default(), window, cx); + }); + save(&editor, &project, cx).await; + assert_eq!("foo", editor.read_with(cx, |editor, cx| editor.text(cx))); + + editor.update_in(cx, |editor, window, cx| { + editor.redo(&Default::default(), window, cx); + }); + cx.run_until_parked(); + assert_eq!("\nfoo", editor.read_with(cx, |editor, cx| editor.text(cx))); + + async fn save(editor: &Entity, project: &Entity, cx: &mut VisualTestContext) { + let save = editor + .update_in(cx, |editor, window, cx| { + editor.save( + SaveOptions { + format: true, + force_format: false, + autosave: false, + }, + project.clone(), + window, + cx, + ) + }) + .unwrap(); + save.await; + assert!(!cx.read(|cx| editor.is_dirty(cx))); + } +} + +#[gpui::test] +async fn test_multibuffer_format_during_save(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let cols = 4; + let rows = 10; + let sample_text_1 = sample_text(rows, cols, 'a'); + assert_eq!( + sample_text_1, + "aaaa\nbbbb\ncccc\ndddd\neeee\nffff\ngggg\nhhhh\niiii\njjjj" + ); + let sample_text_2 = sample_text(rows, cols, 'l'); + assert_eq!( + sample_text_2, + "llll\nmmmm\nnnnn\noooo\npppp\nqqqq\nrrrr\nssss\ntttt\nuuuu" + ); + let sample_text_3 = sample_text(rows, cols, 'v').replace('\u{7f}', "."); + assert_eq!( + sample_text_3, + "vvvv\nwwww\nxxxx\nyyyy\nzzzz\n{{{{\n||||\n}}}}\n~~~~\n...." + ); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/a"), + json!({ + "main.rs": sample_text_1, + "other.rs": sample_text_2, + "lib.rs": sample_text_3, + }), + ) + .await; + + let project = Project::test(fs, [path!("/a").as_ref()], cx).await; + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(*window, cx); + + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(rust_lang()); + let mut fake_servers = language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + capabilities: lsp::ServerCapabilities { + document_formatting_provider: Some(lsp::OneOf::Left(true)), + ..Default::default() + }, + ..Default::default() + }, + ); + + let worktree = project.update(cx, |project, cx| { + let mut worktrees = project.worktrees(cx).collect::>(); + assert_eq!(worktrees.len(), 1); + worktrees.pop().unwrap() + }); + let worktree_id = worktree.update(cx, |worktree, _| worktree.id()); + + let buffer_1 = project + .update(cx, |project, cx| { + project.open_buffer((worktree_id, rel_path("main.rs")), cx) + }) + .await + .unwrap(); + let buffer_2 = project + .update(cx, |project, cx| { + project.open_buffer((worktree_id, rel_path("other.rs")), cx) + }) + .await + .unwrap(); + let buffer_3 = project + .update(cx, |project, cx| { + project.open_buffer((worktree_id, rel_path("lib.rs")), cx) + }) + .await + .unwrap(); + + let multi_buffer = cx.new(|cx| { + let mut multi_buffer = MultiBuffer::new(ReadWrite); + multi_buffer.set_excerpts_for_path( + PathKey::sorted(0), + buffer_1.clone(), + [ + Point::new(0, 0)..Point::new(2, 4), + Point::new(5, 0)..Point::new(6, 4), + Point::new(9, 0)..Point::new(9, 4), + ], + 0, + cx, + ); + multi_buffer.set_excerpts_for_path( + PathKey::sorted(1), + buffer_2.clone(), + [ + Point::new(0, 0)..Point::new(2, 4), + Point::new(5, 0)..Point::new(6, 4), + Point::new(9, 0)..Point::new(9, 4), + ], + 0, + cx, + ); + multi_buffer.set_excerpts_for_path( + PathKey::sorted(2), + buffer_3.clone(), + [ + Point::new(0, 0)..Point::new(2, 4), + Point::new(5, 0)..Point::new(6, 4), + Point::new(9, 0)..Point::new(9, 4), + ], + 0, + cx, + ); + assert_eq!(multi_buffer.read(cx).excerpts().count(), 9); + multi_buffer + }); + let multi_buffer_editor = cx.new_window_entity(|window, cx| { + Editor::new( + EditorMode::full(), + multi_buffer, + Some(project.clone()), + window, + cx, + ) + }); + + multi_buffer_editor.update_in(cx, |editor, window, cx| { + let a = editor.text(cx).find("aaaa").unwrap(); + editor.change_selections( + SelectionEffects::scroll(Autoscroll::Next), + window, + cx, + |s| s.select_ranges(Some(MultiBufferOffset(a + 1)..MultiBufferOffset(a + 2))), + ); + editor.insert("|one|two|three|", window, cx); + }); + assert!(cx.read(|cx| multi_buffer_editor.is_dirty(cx))); + multi_buffer_editor.update_in(cx, |editor, window, cx| { + let n = editor.text(cx).find("nnnn").unwrap(); + editor.change_selections( + SelectionEffects::scroll(Autoscroll::Next), + window, + cx, + |s| s.select_ranges(Some(MultiBufferOffset(n + 4)..MultiBufferOffset(n + 14))), + ); + editor.insert("|four|five|six|", window, cx); + }); + assert!(cx.read(|cx| multi_buffer_editor.is_dirty(cx))); + + // First two buffers should be edited, but not the third one. + pretty_assertions::assert_eq!( + editor_content_with_blocks(&multi_buffer_editor, cx), + indoc! {" + § main.rs + § ----- + a|one|two|three|aa + bbbb + cccc + § ----- + ffff + gggg + § ----- + jjjj + § other.rs + § ----- + llll + mmmm + nnnn|four|five|six| + § ----- + + § ----- + uuuu + § lib.rs + § ----- + vvvv + wwww + xxxx + § ----- + {{{{ + |||| + § ----- + ...."} + ); + buffer_1.update(cx, |buffer, _| { + assert!(buffer.is_dirty()); + assert_eq!( + buffer.text(), + "a|one|two|three|aa\nbbbb\ncccc\ndddd\neeee\nffff\ngggg\nhhhh\niiii\njjjj", + ) + }); + buffer_2.update(cx, |buffer, _| { + assert!(buffer.is_dirty()); + assert_eq!( + buffer.text(), + "llll\nmmmm\nnnnn|four|five|six|\noooo\npppp\n\nssss\ntttt\nuuuu", + ) + }); + buffer_3.update(cx, |buffer, _| { + assert!(!buffer.is_dirty()); + assert_eq!(buffer.text(), sample_text_3,) + }); + cx.executor().run_until_parked(); + + let save = multi_buffer_editor + .update_in(cx, |editor, window, cx| { + editor.save( + SaveOptions { + format: true, + force_format: false, + autosave: false, + }, + project.clone(), + window, + cx, + ) + }) + .unwrap(); + + let fake_server = fake_servers.next().await.unwrap(); + fake_server + .server + .on_request::(move |_params, _| async move { + Ok(Some(vec![lsp::TextEdit::new( + lsp::Range::new(lsp::Position::new(0, 3), lsp::Position::new(1, 0)), + "[formatted]".to_string(), + )])) + }) + .detach(); + save.await; + + // After multibuffer saving, only first two buffers should be reformatted, but not the third one (as it was not dirty). + assert!(cx.read(|cx| !multi_buffer_editor.is_dirty(cx))); + assert_eq!( + editor_content_with_blocks(&multi_buffer_editor, cx), + indoc! {" + § main.rs + § ----- + a|o[formatted]bbbb + cccc + § ----- + ffff + gggg + § ----- + jjjj + + § other.rs + § ----- + lll[formatted]mmmm + nnnn|four|five|six| + § ----- + + § ----- + uuuu + + § lib.rs + § ----- + vvvv + wwww + xxxx + § ----- + {{{{ + |||| + § ----- + ...."} + ); + buffer_1.update(cx, |buffer, _| { + assert!(!buffer.is_dirty()); + assert_eq!( + buffer.text(), + "a|o[formatted]bbbb\ncccc\ndddd\neeee\nffff\ngggg\nhhhh\niiii\njjjj\n", + ) + }); + // Diff < left / right > : + // lll[formatted]mmmm + // nnnn|four|five|six|oooo + // pppp + // < + // ssss + // tttt + // uuuu + + buffer_2.update(cx, |buffer, _| { + assert!(!buffer.is_dirty()); + assert_eq!( + buffer.text(), + "lll[formatted]mmmm\nnnnn|four|five|six|\noooo\npppp\n\nssss\ntttt\nuuuu\n", + ) + }); + buffer_3.update(cx, |buffer, _| { + assert!(!buffer.is_dirty()); + assert_eq!(buffer.text(), sample_text_3,) + }); +} + +#[gpui::test] +async fn test_autosave_with_dirty_buffers(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/dir"), + json!({ + "file1.rs": "fn main() { println!(\"hello\"); }", + "file2.rs": "fn test() { println!(\"test\"); }", + "file3.rs": "fn other() { println!(\"other\"); }\n", + }), + ) + .await; + + let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(*window, cx); + + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(rust_lang()); + + let worktree = project.update(cx, |project, cx| project.worktrees(cx).next().unwrap()); + let worktree_id = worktree.update(cx, |worktree, _| worktree.id()); + + // Open three buffers + let buffer_1 = project + .update(cx, |project, cx| { + project.open_buffer((worktree_id, rel_path("file1.rs")), cx) + }) + .await + .unwrap(); + let buffer_2 = project + .update(cx, |project, cx| { + project.open_buffer((worktree_id, rel_path("file2.rs")), cx) + }) + .await + .unwrap(); + let buffer_3 = project + .update(cx, |project, cx| { + project.open_buffer((worktree_id, rel_path("file3.rs")), cx) + }) + .await + .unwrap(); + + // Create a multi-buffer with all three buffers + let multi_buffer = cx.new(|cx| { + let mut multi_buffer = MultiBuffer::new(ReadWrite); + multi_buffer.set_excerpts_for_path( + PathKey::sorted(0), + buffer_1.clone(), + [Point::new(0, 0)..Point::new(1, 0)], + 0, + cx, + ); + multi_buffer.set_excerpts_for_path( + PathKey::sorted(1), + buffer_2.clone(), + [Point::new(0, 0)..Point::new(1, 0)], + 0, + cx, + ); + multi_buffer.set_excerpts_for_path( + PathKey::sorted(2), + buffer_3.clone(), + [Point::new(0, 0)..Point::new(1, 0)], + 0, + cx, + ); + multi_buffer + }); + + let editor = cx.new_window_entity(|window, cx| { + Editor::new( + EditorMode::full(), + multi_buffer, + Some(project.clone()), + window, + cx, + ) + }); + + // Edit only the first buffer + editor.update_in(cx, |editor, window, cx| { + editor.change_selections( + SelectionEffects::scroll(Autoscroll::Next), + window, + cx, + |s| s.select_ranges(Some(MultiBufferOffset(10)..MultiBufferOffset(10))), + ); + editor.insert("// edited", window, cx); + }); + + // Verify that only buffer 1 is dirty + buffer_1.update(cx, |buffer, _| assert!(buffer.is_dirty())); + buffer_2.update(cx, |buffer, _| assert!(!buffer.is_dirty())); + buffer_3.update(cx, |buffer, _| assert!(!buffer.is_dirty())); + + // Get write counts after file creation (files were created with initial content) + // We expect each file to have been written once during creation + let write_count_after_creation_1 = fs.write_count_for_path(path!("/dir/file1.rs")); + let write_count_after_creation_2 = fs.write_count_for_path(path!("/dir/file2.rs")); + let write_count_after_creation_3 = fs.write_count_for_path(path!("/dir/file3.rs")); + + // Perform autosave + let save_task = editor.update_in(cx, |editor, window, cx| { + editor.save( + SaveOptions { + format: true, + force_format: false, + autosave: true, + }, + project.clone(), + window, + cx, + ) + }); + save_task.await.unwrap(); + + // Only the dirty buffer should have been saved + assert_eq!( + fs.write_count_for_path(path!("/dir/file1.rs")) - write_count_after_creation_1, + 1, + "Buffer 1 was dirty, so it should have been written once during autosave" + ); + assert_eq!( + fs.write_count_for_path(path!("/dir/file2.rs")) - write_count_after_creation_2, + 0, + "Buffer 2 was clean, so it should not have been written during autosave" + ); + assert_eq!( + fs.write_count_for_path(path!("/dir/file3.rs")) - write_count_after_creation_3, + 0, + "Buffer 3 was clean, so it should not have been written during autosave" + ); + + // Verify buffer states after autosave + buffer_1.update(cx, |buffer, _| assert!(!buffer.is_dirty())); + buffer_2.update(cx, |buffer, _| assert!(!buffer.is_dirty())); + buffer_3.update(cx, |buffer, _| assert!(!buffer.is_dirty())); + + // Now perform a manual save (format = true) + let save_task = editor.update_in(cx, |editor, window, cx| { + editor.save( + SaveOptions { + format: true, + force_format: false, + autosave: false, + }, + project.clone(), + window, + cx, + ) + }); + save_task.await.unwrap(); + + // During manual save, clean buffers don't get written to disk + // They just get did_save called for language server notifications + assert_eq!( + fs.write_count_for_path(path!("/dir/file1.rs")) - write_count_after_creation_1, + 1, + "Buffer 1 should only have been written once total (during autosave, not manual save)" + ); + assert_eq!( + fs.write_count_for_path(path!("/dir/file2.rs")) - write_count_after_creation_2, + 0, + "Buffer 2 should not have been written at all" + ); + assert_eq!( + fs.write_count_for_path(path!("/dir/file3.rs")) - write_count_after_creation_3, + 0, + "Buffer 3 should not have been written at all" + ); +} + +#[gpui::test] +async fn test_format_selections_action_available_when_range_formatting_is_supported( + cx: &mut TestAppContext, +) { + let (_, editor, cx, _) = setup_range_format_test(cx).await; + + editor.update_in(cx, |editor, window, cx| { + editor.set_text("one\ntwo\nthree\n", window, cx); + editor.change_selections(SelectionEffects::default(), window, cx, |s| { + s.select_ranges([Point::new(0, 0)..Point::new(1, 0)]); + }); + }); + + refresh_editor_actions(cx); + + assert!(cx.update(|window, cx| { window.is_action_available(&FormatSelections, cx) })); +} + +#[gpui::test] +async fn test_format_selections_action_available_for_cursor_when_range_formatting_is_supported( + cx: &mut TestAppContext, +) { + let (_, editor, cx, _) = setup_range_format_test(cx).await; + + editor.update_in(cx, |editor, window, cx| { + editor.set_text("foo\nbar\n", window, cx); + editor.change_selections(SelectionEffects::default(), window, cx, |s| { + s.select_ranges([Point::new(1, 1)..Point::new(1, 1)]); + }); + }); + + refresh_editor_actions(cx); + + assert!(cx.update(|window, cx| { window.is_action_available(&FormatSelections, cx) })); +} + +#[gpui::test] +async fn test_format_selections_action_hidden_without_range_formatting_support( + cx: &mut TestAppContext, +) { + let (_, editor, cx, _) = setup_range_format_test_with_capabilities( + cx, + lsp::ServerCapabilities { + document_formatting_provider: Some(lsp::OneOf::Left(true)), + document_range_formatting_provider: Some(lsp::OneOf::Left(false)), + ..lsp::ServerCapabilities::default() + }, + ) + .await; + + editor.update_in(cx, |editor, window, cx| { + editor.set_text("one\ntwo\nthree\n", window, cx); + editor.change_selections(SelectionEffects::default(), window, cx, |s| { + s.select_ranges([Point::new(0, 0)..Point::new(1, 0)]); + }); + }); + + refresh_editor_actions(cx); + + assert!(!cx.update(|window, cx| { window.is_action_available(&FormatSelections, cx) })); +} + +#[gpui::test] +async fn test_format_selections_action_hidden_without_range_capable_formatter( + cx: &mut TestAppContext, +) { + init_test(cx, |settings| { + settings.defaults.formatter = Some(FormatterList::Single(Formatter::External { + command: "awk".into(), + arguments: Some(vec!["{ print }".to_string()]), + })); + }); + + let fs = FakeFs::new(cx.executor()); + fs.insert_file(path!("/file.rs"), Default::default()).await; + + let project = Project::test(fs, [path!("/").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(rust_lang()); + let _ = language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + capabilities: lsp::ServerCapabilities { + document_range_formatting_provider: Some(lsp::OneOf::Left(true)), + ..lsp::ServerCapabilities::default() + }, + ..FakeLspAdapter::default() + }, + ); + + let buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/file.rs"), cx) + }) + .await + .unwrap(); + + let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + let (editor, cx) = cx.add_window_view(|window, cx| { + build_editor_with_project(project.clone(), buffer, window, cx) + }); + editor.update_in(cx, |editor, window, cx| { + window.focus(&editor.focus_handle(cx), cx); + }); + + editor.update_in(cx, |editor, window, cx| { + editor.set_text("one\ntwo\nthree\n", window, cx); + editor.change_selections(SelectionEffects::default(), window, cx, |s| { + s.select_ranges([Point::new(0, 0)..Point::new(1, 0)]); + }); + }); + + refresh_editor_actions(cx); + + assert!(!cx.update(|window, cx| { window.is_action_available(&FormatSelections, cx) })); +} + +#[gpui::test] +async fn test_range_format_on_save_success(cx: &mut TestAppContext) { + let (project, editor, cx, fake_server) = setup_range_format_test(cx).await; + + editor.update_in(cx, |editor, window, cx| { + editor.set_text("one\ntwo\nthree\n", window, cx) + }); + assert!(cx.read(|cx| editor.is_dirty(cx))); + + let save = editor + .update_in(cx, |editor, window, cx| { + editor.save( + SaveOptions { + format: true, + force_format: false, + autosave: false, + }, + project.clone(), + window, + cx, + ) + }) + .unwrap(); + fake_server + .set_request_handler::(move |params, _| async move { + assert_eq!( + params.text_document.uri, + lsp::Uri::from_file_path(path!("/file.rs")).unwrap() + ); + assert_eq!(params.options.tab_size, 4); + Ok(Some(vec![lsp::TextEdit::new( + lsp::Range::new(lsp::Position::new(0, 3), lsp::Position::new(1, 0)), + ", ".to_string(), + )])) + }) + .next() + .await; + save.await; + assert_eq!( + editor.update(cx, |editor, cx| editor.text(cx)), + "one, two\nthree\n" + ); + assert!(!cx.read(|cx| editor.is_dirty(cx))); +} + +#[gpui::test] +async fn test_range_format_on_save_timeout(cx: &mut TestAppContext) { + let (project, editor, cx, fake_server) = setup_range_format_test(cx).await; + + editor.update_in(cx, |editor, window, cx| { + editor.set_text("one\ntwo\nthree\n", window, cx) + }); + assert!(cx.read(|cx| editor.is_dirty(cx))); + + // Test that save still works when formatting hangs + fake_server.set_request_handler::( + move |params, _| async move { + assert_eq!( + params.text_document.uri, + lsp::Uri::from_file_path(path!("/file.rs")).unwrap() + ); + futures::future::pending::<()>().await; + unreachable!() + }, + ); + let save = editor + .update_in(cx, |editor, window, cx| { + editor.save( + SaveOptions { + format: true, + force_format: false, + autosave: false, + }, + project.clone(), + window, + cx, + ) + }) + .unwrap(); + cx.executor().advance_clock(super::FORMAT_TIMEOUT); + save.await; + assert_eq!( + editor.update(cx, |editor, cx| editor.text(cx)), + "one\ntwo\nthree\n" + ); + assert!(!cx.read(|cx| editor.is_dirty(cx))); +} + +#[gpui::test] +async fn test_modifications_format_on_save(cx: &mut TestAppContext) { + let (project, editor, cx, fake_server) = setup_range_format_test_with_git(cx, "", "").await; + + update_test_language_settings(cx, &|settings| { + settings.defaults.format_on_save = Some(FormatOnSave::Modifications); + }); + + editor.update_in(cx, |editor, window, cx| { + editor.set_text("one\ntwo\nthree\n", window, cx) + }); + cx.run_until_parked(); + assert!(cx.read(|cx| editor.is_dirty(cx))); + + let save = editor + .update_in(cx, |editor, window, cx| { + editor.save( + SaveOptions { + format: true, + autosave: false, + force_format: false, + }, + project.clone(), + window, + cx, + ) + }) + .unwrap(); + fake_server + .set_request_handler::(move |params, _| async move { + assert_eq!( + params.text_document.uri, + lsp::Uri::from_file_path(path!("/project/file.rs")).unwrap() + ); + Ok(Some(vec![lsp::TextEdit::new( + lsp::Range::new(lsp::Position::new(0, 3), lsp::Position::new(1, 0)), + ", ".to_string(), + )])) + }) + .next() + .await; + save.await; + assert_eq!( + editor.update(cx, |editor, cx| editor.text(cx)), + "one, two\nthree\n" + ); + assert!(!cx.read(|cx| editor.is_dirty(cx))); +} + +#[gpui::test] +async fn test_modifications_format_skips_without_git_diff(cx: &mut TestAppContext) { + let (project, editor, cx, fake_server) = setup_range_format_test(cx).await; + + update_test_language_settings(cx, &|settings| { + settings.defaults.format_on_save = Some(FormatOnSave::Modifications); + }); + + editor.update_in(cx, |editor, window, cx| { + editor.set_text("one\ntwo\nthree\n", window, cx) + }); + assert!( + cx.read(|cx| editor.is_dirty(cx)), + "editor should be dirty before save" + ); + + let _no_range_format_handler = fake_server + .set_request_handler::(move |_, _| async move { + panic!("rangeFormatting must not be called when no git diff is available"); + }) + .next(); + let _no_format_handler = fake_server + .set_request_handler::(move |_, _| async move { + panic!("full formatting must not be called for Modifications without a git diff"); + }) + .next(); + + let save = editor + .update_in(cx, |editor, window, cx| { + editor.save( + SaveOptions { + format: true, + autosave: false, + force_format: false, + }, + project.clone(), + window, + cx, + ) + }) + .unwrap(); + save.await; + cx.run_until_parked(); + assert!( + !cx.read(|cx| editor.is_dirty(cx)), + "the buffer should be saved despite the skipped formatting" + ); + assert_eq!( + editor.update(cx, |editor, cx| editor.text(cx)), + "one\ntwo\nthree\n" + ); +} + +#[gpui::test] +async fn test_modifications_format_lsp_no_range_support(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/project"), + json!({ + ".git": {}, + "file.rs": "", + }), + ) + .await; + + let head_content = "one\ntwo\nthree\n"; + fs.set_head_and_index_for_repo( + std::path::Path::new(path!("/project/.git")), + &[("file.rs", head_content.to_string())], + ); + + let project = Project::test(fs, [path!("/project").as_ref()], cx).await; + + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(rust_lang()); + let mut fake_servers = language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + capabilities: lsp::ServerCapabilities { + document_range_formatting_provider: Some(lsp::OneOf::Left(false)), + document_formatting_provider: Some(lsp::OneOf::Left(true)), + ..lsp::ServerCapabilities::default() + }, + ..FakeLspAdapter::default() + }, + ); + + let buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/project/file.rs"), cx) + }) + .await + .unwrap(); + project + .update(cx, |project, cx| { + project.open_unstaged_diff(buffer.clone(), cx) + }) + .await + .unwrap(); + + let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + let (editor, cx) = cx.add_window_view(|window, cx| { + build_editor_with_project(project.clone(), buffer, window, cx) + }); + + let fake_server = fake_servers.next().await.unwrap(); + + update_test_language_settings(cx, &|settings| { + settings.defaults.format_on_save = Some(FormatOnSave::Modifications); + }); + + editor.update_in(cx, |editor, window, cx| { + editor.set_text("one\nTWO\nthree\n", window, cx) + }); + cx.run_until_parked(); + assert!(cx.read(|cx| editor.is_dirty(cx))); + + let _no_range_format_handler = fake_server + .set_request_handler::(move |_, _| async move { + panic!("rangeFormatting must not be called when range formatting is unsupported"); + }) + .next(); + let _no_format_handler = fake_server + .set_request_handler::(move |_, _| async move { + panic!("full formatting must not be called for Modifications when range formatting is unsupported"); + }) + .next(); + + let save = editor + .update_in(cx, |editor, window, cx| { + editor.save( + SaveOptions { + format: true, + autosave: false, + force_format: false, + }, + project.clone(), + window, + cx, + ) + }) + .unwrap(); + save.await; + cx.run_until_parked(); + assert!(!cx.read(|cx| editor.is_dirty(cx))); + assert_eq!( + editor.update(cx, |editor, cx| editor.text(cx)), + "one\nTWO\nthree\n" + ); +} + +#[gpui::test] +async fn test_modifications_format_lsp_returns_empty_edits(cx: &mut TestAppContext) { + let (project, editor, cx, fake_server) = setup_range_format_test_with_git(cx, "", "").await; + + update_test_language_settings(cx, &|settings| { + settings.defaults.format_on_save = Some(FormatOnSave::Modifications); + }); + + editor.update_in(cx, |editor, window, cx| { + editor.set_text("aaa\nbbb\nccc\nddd\neee\n", window, cx) + }); + cx.run_until_parked(); + assert!(cx.read(|cx| editor.is_dirty(cx))); + + let save = editor + .update_in(cx, |editor, window, cx| { + editor.save( + SaveOptions { + format: true, + autosave: false, + force_format: false, + }, + project.clone(), + window, + cx, + ) + }) + .unwrap(); + fake_server + .set_request_handler::(move |params, _| async move { + assert_eq!( + params.text_document.uri, + lsp::Uri::from_file_path(path!("/project/file.rs")).unwrap() + ); + Ok(Some(Vec::new())) + }) + .next() + .await; + save.await; + assert!(!cx.read(|cx| editor.is_dirty(cx))); + assert_eq!( + editor.update(cx, |editor, cx| editor.text(cx)), + "aaa\nbbb\nccc\nddd\neee\n" + ); +} + +#[gpui::test] +async fn test_modifications_format_multiple_hunks(cx: &mut TestAppContext) { + let head_content = "line0\nline1\nline2\nline3\nline4\nline5\n"; + let (project, editor, cx, fake_server) = + setup_range_format_test_with_git(cx, head_content, head_content).await; + + update_test_language_settings(cx, &|settings| { + settings.defaults.format_on_save = Some(FormatOnSave::Modifications); + }); + + editor.update_in(cx, |editor, window, cx| { + editor.set_text("line0\nLINE1\nline2\nline3\nLINE4\nline5\n", window, cx); + }); + cx.run_until_parked(); + assert!(cx.read(|cx| editor.is_dirty(cx))); + + let request_count = Arc::new(AtomicUsize::new(0)); + let request_count_clone = request_count.clone(); + let mut responded_rx = + fake_server.set_request_handler::(move |params, _| { + let count = request_count_clone.fetch_add(1, atomic::Ordering::SeqCst); + async move { + assert_eq!( + params.text_document.uri, + lsp::Uri::from_file_path(path!("/project/file.rs")).unwrap() + ); + match count { + 0 => Ok(Some(vec![lsp::TextEdit::new( + lsp::Range::new(lsp::Position::new(1, 5), lsp::Position::new(1, 5)), + "!".to_string(), + )])), + 1 => Ok(Some(vec![lsp::TextEdit::new( + lsp::Range::new(lsp::Position::new(4, 5), lsp::Position::new(4, 5)), + "!".to_string(), + )])), + _ => panic!("unexpected third range formatting request"), + } + } + }); + + let save = editor + .update_in(cx, |editor, window, cx| { + editor.save( + SaveOptions { + format: true, + autosave: false, + force_format: false, + }, + project.clone(), + window, + cx, + ) + }) + .unwrap(); + responded_rx.next().await; + responded_rx.next().await; + save.await; + + assert_eq!(request_count.load(atomic::Ordering::SeqCst), 2); + assert_eq!( + editor.update(cx, |editor, cx| editor.text(cx)), + "line0\nLINE1!\nline2\nline3\nLINE4!\nline5\n" + ); + assert!(!cx.read(|cx| editor.is_dirty(cx))); +} + +#[gpui::test] +async fn test_modifications_format_overlapping_range_responses(cx: &mut TestAppContext) { + assert_range_format_merge( + cx, + "line0\nline1\nline2\nline3\nline4\nline5\n", + "line0\nLINE1\nline2\nline3\nLINE4\nline5\n", + vec![vec![lsp_line_edit(0, 6, "fmt0\nfmt1\nfmt2\nfmt3\nfmt4\nfmt5\n")]], + "fmt0\nfmt1\nfmt2\nfmt3\nfmt4\nfmt5\n", + "a response covering the second hunk must suppress its request and must not duplicate the formatted block", + ) + .await; +} + +#[gpui::test] +async fn test_modifications_format_divergent_overlapping_responses(cx: &mut TestAppContext) { + assert_range_format_merge( + cx, + "line0\nline1\nline2\nline3\nline4\nline5\n", + "line0\nLINE1\nline2\nline3\nLINE4\nline5\n", + vec![ + vec![lsp_line_edit(1, 3, "one\ntwo\n")], + vec![lsp_line_edit(2, 5, "TWO\nthree\nfour\n")], + ], + "line0\none\ntwo\nline3\nLINE4\nline5\n", + "the first edit must win and the divergent overlapping edit must be dropped without corrupting the buffer", + ) + .await; +} + +#[gpui::test] +async fn test_modifications_format_partially_overlapping_response_dropped_atomically( + cx: &mut TestAppContext, +) { + assert_range_format_merge( + cx, + "line0\nline1\nline2\nline3\nline4\nline5\n", + "line0\nLINE1\nline2\nline3\nLINE4\nline5\n", + vec![ + vec![lsp_line_edit(1, 2, "one\n")], + vec![ + lsp_line_edit(4, 5, "four\n"), + lsp_line_edit(1, 2, "ONE\n"), + ], + ], + "line0\none\nline2\nline3\nLINE4\nline5\n", + "a response with any divergent overlapping edit must be dropped whole, including its non-overlapping edits", + ) + .await; +} + +#[gpui::test] +async fn test_modifications_format_overlapping_edits_within_one_response(cx: &mut TestAppContext) { + assert_range_format_merge( + cx, + "line0\nline1\nline2\nline3\nline4\nline5\n", + "line0\nLINE1\nline2\nline3\nline4\nline5\n", + vec![vec![ + lsp_line_edit(1, 2, "one\n"), + lsp_line_edit(1, 2, "uno\n"), + ]], + "line0\none\nline2\nline3\nline4\nline5\n", + "the later overlapping edit within one response must be dropped", + ) + .await; +} + +#[gpui::test] +async fn test_modifications_format_same_position_inserts_within_one_response( + cx: &mut TestAppContext, +) { + assert_range_format_merge( + cx, + "line0\nline1\nline2\n", + "line0\nLINE1\nline2\n", + vec![vec![ + lsp_line_edit(1, 1, "alpha\n"), + lsp_line_edit(1, 1, "beta\n"), + ]], + "line0\nalpha\nbeta\nLINE1\nline2\n", + "multiple inserts at one position within one response are valid per the LSP spec and must all apply in array order", + ) + .await; +} + +#[gpui::test] +async fn test_modifications_format_divergent_same_position_inserts_across_responses( + cx: &mut TestAppContext, +) { + assert_range_format_merge( + cx, + "line0\nline1\nline2\nline3\nline4\nline5\n", + "line0\nLINE1\nline2\nline3\nLINE4\nline5\n", + vec![ + vec![lsp_line_edit(4, 4, "alpha\n")], + vec![lsp_line_edit(4, 4, "beta\n")], + ], + "line0\nLINE1\nline2\nline3\nalpha\nLINE4\nline5\n", + "a later response inserting different text at the same position diverges and must be dropped", + ) + .await; +} + +#[gpui::test] +async fn test_modifications_format_insert_touching_kept_edit_across_responses( + cx: &mut TestAppContext, +) { + assert_range_format_merge( + cx, + "line0\nline1\nline2\nline3\nline4\nline5\n", + "line0\nLINE1\nline2\nline3\nLINE4\nline5\n", + vec![ + vec![ + lsp_line_edit(1, 2, "one\n"), + lsp_line_edit(4, 4, "alpha\n"), + ], + vec![lsp_line_edit(4, 5, "alpha\nfour\n")], + ], + "line0\none\nline2\nline3\nalpha\nLINE4\nline5\n", + "an edit touching a kept insert at its boundary can duplicate the inserted text and must be dropped as divergent", + ) + .await; +} + +#[gpui::test] +async fn test_modifications_format_excludes_staged_changes(cx: &mut TestAppContext) { + let head_content = "line0\nline1\nline2\nline3\nline4\n"; + let staged_content = "line0\nLINE1\nline2\nline3\nline4\n"; + let (project, editor, cx, fake_server) = + setup_range_format_test_with_git(cx, head_content, staged_content).await; + + update_test_language_settings(cx, &|settings| { + settings.defaults.format_on_save = Some(FormatOnSave::Modifications); + }); + + let working_content = "line0\nLINE1\nline2\nline3\nLINE4\n"; + editor.update_in(cx, |editor, window, cx| { + editor.set_text(working_content, window, cx) + }); + cx.run_until_parked(); + + let request_count = Arc::new(AtomicUsize::new(0)); + let request_count_clone = request_count.clone(); + let mut responded_rx = + fake_server.set_request_handler::(move |params, _| { + request_count_clone.fetch_add(1, atomic::Ordering::SeqCst); + assert_eq!( + params.range.start.line, 4, + "only the unstaged hunk (LINE4) should be formatted" + ); + async move { Ok(Some(Vec::new())) } + }); + + let save = editor + .update_in(cx, |editor, window, cx| { + editor.save( + SaveOptions { + format: true, + autosave: false, + force_format: false, + }, + project.clone(), + window, + cx, + ) + }) + .unwrap(); + responded_rx.next().await; + save.await; + + assert_eq!( + request_count.load(atomic::Ordering::SeqCst), + 1, + "staged hunk (LINE1) must not be formatted, only the unstaged hunk (LINE4)" + ); +} + +#[gpui::test] +async fn test_modifications_format_range_excludes_staged_hunk(cx: &mut TestAppContext) { + let head_content = "a\nb\nc\n"; + let staged_content = "A\nb\nc\n"; + let (project, editor, cx, fake_server) = + setup_range_format_test_with_git(cx, head_content, staged_content).await; + + update_test_language_settings(cx, &|settings| { + settings.defaults.format_on_save = Some(FormatOnSave::Modifications); + }); + + editor.update_in(cx, |editor, window, cx| { + editor.set_text("A\nB\nc\n", window, cx) + }); + cx.run_until_parked(); + + let captured_start_line = Arc::new(AtomicUsize::new(usize::MAX)); + let captured_start_line_clone = captured_start_line.clone(); + let mut responded_rx = + fake_server.set_request_handler::(move |params, _| { + captured_start_line_clone + .store(params.range.start.line as usize, atomic::Ordering::SeqCst); + async move { Ok(Some(Vec::new())) } + }); + + let save = editor + .update_in(cx, |editor, window, cx| { + editor.save( + SaveOptions { + format: true, + autosave: false, + force_format: false, + }, + project.clone(), + window, + cx, + ) + }) + .unwrap(); + responded_rx.next().await; + save.await; + + let start_line = captured_start_line.load(atomic::Ordering::SeqCst); + assert_ne!( + start_line, + usize::MAX, + "expected at least one range formatting request" + ); + assert_eq!( + start_line, 1, + "format range must exclude the staged row and start at the unstaged row" + ); +} + +#[gpui::test] +async fn test_modifications_format_pure_unstaged_with_git(cx: &mut TestAppContext) { + let head_content = "line0\nline1\nline2\nline3\nline4\n"; + let (project, editor, cx, fake_server) = + setup_range_format_test_with_git(cx, head_content, head_content).await; + + update_test_language_settings(cx, &|settings| { + settings.defaults.format_on_save = Some(FormatOnSave::Modifications); + }); + + let working_content = "line0\nLINE1\nline2\nline3\nLINE4\n"; + editor.update_in(cx, |editor, window, cx| { + editor.set_text(working_content, window, cx) + }); + cx.run_until_parked(); + + let request_count = Arc::new(AtomicUsize::new(0)); + let request_count_clone = request_count.clone(); + let mut responded_rx = fake_server.set_request_handler::( + move |_params, _| { + request_count_clone.fetch_add(1, atomic::Ordering::SeqCst); + async move { Ok(Some(Vec::new())) } + }, + ); + + let save = editor + .update_in(cx, |editor, window, cx| { + editor.save( + SaveOptions { + format: true, + autosave: false, + force_format: false, + }, + project.clone(), + window, + cx, + ) + }) + .unwrap(); + responded_rx.next().await; + responded_rx.next().await; + save.await; + + assert_eq!( + request_count.load(atomic::Ordering::SeqCst), + 2, + "unstaged hunks (LINE1, LINE4) must be formatted via the git diff path" + ); +} + +#[gpui::test] +async fn test_modifications_format_no_unstaged_changes_with_git(cx: &mut TestAppContext) { + let head_content = "line0\nline1\nline2\nline3\nline4\n"; + let staged_content = "line0\nLINE1\nline2\nline3\nLINE4\n"; + let (project, editor, cx, fake_server) = + setup_range_format_test_with_git(cx, head_content, staged_content).await; + + update_test_language_settings(cx, &|settings| { + settings.defaults.format_on_save = Some(FormatOnSave::Modifications); + }); + + editor.update_in(cx, |editor, window, cx| { + editor.set_text(staged_content, window, cx) + }); + cx.run_until_parked(); + + let _no_format_handler = fake_server + .set_request_handler::(move |_, _| async move { + panic!("range formatting must not be called when all changes are staged"); + }) + .next(); + + let save = editor + .update_in(cx, |editor, window, cx| { + editor.save( + SaveOptions { + format: true, + autosave: false, + force_format: false, + }, + project.clone(), + window, + cx, + ) + }) + .unwrap(); + save.await; + cx.run_until_parked(); +} + +#[gpui::test] +async fn test_modifications_format_no_changes_with_git(cx: &mut TestAppContext) { + let head_content = "line0\nline1\nline2\n"; + let (project, editor, cx, fake_server) = + setup_range_format_test_with_git(cx, head_content, head_content).await; + + update_test_language_settings(cx, &|settings| { + settings.defaults.format_on_save = Some(FormatOnSave::Modifications); + }); + + editor.update_in(cx, |editor, window, cx| { + editor.set_text(head_content, window, cx) + }); + cx.run_until_parked(); + + let _no_format_handler = fake_server + .set_request_handler::(move |_, _| async move { + panic!("range formatting must not be called when buffer matches HEAD"); + }) + .next(); + + let save = editor + .update_in(cx, |editor, window, cx| { + editor.save( + SaveOptions { + format: true, + autosave: false, + force_format: false, + }, + project.clone(), + window, + cx, + ) + }) + .unwrap(); + save.await; + cx.run_until_parked(); +} + +#[gpui::test] +async fn test_modifications_format_crlf_line_endings(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/project"), + json!({ + ".git": {}, + "file.rs": "line0\r\nline1\r\nline2\r\nline3\r\nline4\r\n", + }), + ) + .await; + + let head_content = "line0\nline1\nline2\nline3\nline4\n"; + fs.set_head_for_repo( + std::path::Path::new(path!("/project/.git")), + &[("file.rs", head_content.to_string())], + "deadbeef", + ); + fs.set_index_for_repo( + std::path::Path::new(path!("/project/.git")), + &[("file.rs", head_content.to_string())], + ); + + let project = Project::test(fs, [path!("/project").as_ref()], cx).await; + + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(rust_lang()); + let mut fake_servers = language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + capabilities: lsp::ServerCapabilities { + document_range_formatting_provider: Some(lsp::OneOf::Left(true)), + ..lsp::ServerCapabilities::default() + }, + ..FakeLspAdapter::default() + }, + ); + + let buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/project/file.rs"), cx) + }) + .await + .unwrap(); + + buffer.read_with(cx, |buffer, _| { + assert_eq!( + buffer.line_ending(), + language::LineEnding::Windows, + "buffer should detect CRLF line endings from the working tree file" + ); + }); + + project + .update(cx, |project, cx| { + project.open_unstaged_diff(buffer.clone(), cx) + }) + .await + .unwrap(); + + let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + let (editor, cx) = cx.add_window_view(|window, cx| { + build_editor_with_project(project.clone(), buffer, window, cx) + }); + editor.update_in(cx, |editor, window, cx| { + window.focus(&editor.focus_handle(cx), cx); + }); + + let fake_server = fake_servers.next().await.unwrap(); + + update_test_language_settings(cx, &|settings| { + settings.defaults.format_on_save = Some(FormatOnSave::Modifications); + }); + + editor.update_in(cx, |editor, window, cx| { + editor.set_text("line0\nLINE1\nline2\nline3\nLINE4\n", window, cx) + }); + cx.run_until_parked(); + + let request_count = Arc::new(AtomicUsize::new(0)); + let request_count_clone = request_count.clone(); + let mut responded_rx = fake_server.set_request_handler::( + move |_params, _| { + request_count_clone.fetch_add(1, atomic::Ordering::SeqCst); + async move { Ok(Some(Vec::new())) } + }, + ); + + let save = editor + .update_in(cx, |editor, window, cx| { + editor.save( + SaveOptions { + format: true, + autosave: false, + force_format: false, + }, + project.clone(), + window, + cx, + ) + }) + .unwrap(); + responded_rx.next().await; + responded_rx.next().await; + save.await; + + assert_eq!( + request_count.load(atomic::Ordering::SeqCst), + 2, + "CRLF line endings must not cause spurious diff hunks" + ); +} + +#[gpui::test] +async fn test_modifications_format_merge_boundary_one_row_gap(cx: &mut TestAppContext) { + let head_content = "line0\nline1\nline2\nline3\nline4\n"; + let (project, editor, cx, fake_server) = + setup_range_format_test_with_git(cx, head_content, head_content).await; + + update_test_language_settings(cx, &|settings| { + settings.defaults.format_on_save = Some(FormatOnSave::Modifications); + }); + + let working_content = "line0\nLINE1\nline2\nLINE3\nline4\n"; + editor.update_in(cx, |editor, window, cx| { + editor.set_text(working_content, window, cx) + }); + cx.run_until_parked(); + + let request_count = Arc::new(AtomicUsize::new(0)); + let request_count_clone = request_count.clone(); + let mut responded_rx = fake_server.set_request_handler::( + move |_params, _| { + request_count_clone.fetch_add(1, atomic::Ordering::SeqCst); + async move { Ok(Some(Vec::new())) } + }, + ); + + let save = editor + .update_in(cx, |editor, window, cx| { + editor.save( + SaveOptions { + format: true, + autosave: false, + force_format: false, + }, + project.clone(), + window, + cx, + ) + }) + .unwrap(); + responded_rx.next().await; + responded_rx.next().await; + save.await; + + assert_eq!( + request_count.load(atomic::Ordering::SeqCst), + 2, + "hunks separated by exactly one unchanged row must not merge" + ); +} + +#[gpui::test] +async fn test_modifications_if_available_empty_diff_skips_formatting(cx: &mut TestAppContext) { + let head_content = "line0\nline1\nline2\n"; + let (project, editor, cx, fake_server) = + setup_range_format_test_with_git(cx, head_content, head_content).await; + + update_test_language_settings(cx, &|settings| { + settings.defaults.format_on_save = Some(FormatOnSave::ModificationsIfAvailable); + }); + + editor.update_in(cx, |editor, window, cx| { + editor.set_text(head_content, window, cx) + }); + cx.run_until_parked(); + + let _no_range_format = fake_server + .set_request_handler::(move |_, _| async move { + panic!("range formatting must not be called when diff is empty"); + }) + .next(); + let _no_format = fake_server + .set_request_handler::(move |_, _| async move { + panic!("full formatting must not be called when a diff is available but empty"); + }) + .next(); + + let save = editor + .update_in(cx, |editor, window, cx| { + editor.save( + SaveOptions { + format: true, + autosave: false, + force_format: false, + }, + project.clone(), + window, + cx, + ) + }) + .unwrap(); + save.await; + cx.run_until_parked(); + assert!( + !cx.read(|cx| editor.is_dirty(cx)), + "the buffer should be saved despite the skipped formatting" + ); +} + +#[gpui::test] +async fn test_modifications_if_available_no_git_falls_back_to_full_format(cx: &mut TestAppContext) { + let (project, editor, cx, fake_server) = setup_range_format_test_with_capabilities( + cx, + lsp::ServerCapabilities { + document_range_formatting_provider: Some(lsp::OneOf::Left(true)), + document_formatting_provider: Some(lsp::OneOf::Left(true)), + ..lsp::ServerCapabilities::default() + }, + ) + .await; + + update_test_language_settings(cx, &|settings| { + settings.defaults.format_on_save = Some(FormatOnSave::ModificationsIfAvailable); + }); + + editor.update_in(cx, |editor, window, cx| { + editor.set_text("one\ntwo\nthree\n", window, cx) + }); + assert!(cx.read(|cx| editor.is_dirty(cx))); + + let _no_range_format = fake_server + .set_request_handler::(move |_, _| async move { + panic!("range formatting must not be called when no git diff is available"); + }) + .next(); + + let formatting_called = Arc::new(AtomicBool::new(false)); + let formatting_called_clone = formatting_called.clone(); + let mut formatting_rx = + fake_server.set_request_handler::(move |_, _| { + formatting_called_clone.store(true, atomic::Ordering::SeqCst); + async move { Ok(Some(Vec::new())) } + }); + + let save = editor + .update_in(cx, |editor, window, cx| { + editor.save( + SaveOptions { + format: true, + autosave: false, + force_format: false, + }, + project.clone(), + window, + cx, + ) + }) + .unwrap(); + formatting_rx.next().await; + save.await; + + assert!( + formatting_called.load(atomic::Ordering::SeqCst), + "ModificationsIfAvailable must fall back to full-buffer formatting when no git diff is available" + ); +} + +#[gpui::test] +async fn test_modifications_if_available_lsp_no_range_support_falls_back_to_full_format( + cx: &mut TestAppContext, +) { + init_test(cx, |_| {}); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/project"), + json!({ + ".git": {}, + "file.rs": "", + }), + ) + .await; + + let head_content = "line0\nline1\nline2\n"; + fs.set_head_for_repo( + std::path::Path::new(path!("/project/.git")), + &[("file.rs", head_content.to_string())], + "deadbeef", + ); + fs.set_index_for_repo( + std::path::Path::new(path!("/project/.git")), + &[("file.rs", head_content.to_string())], + ); + + let project = Project::test(fs, [path!("/project").as_ref()], cx).await; + + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(rust_lang()); + let mut fake_servers = language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + capabilities: lsp::ServerCapabilities { + document_range_formatting_provider: Some(lsp::OneOf::Left(false)), + document_formatting_provider: Some(lsp::OneOf::Left(true)), + ..lsp::ServerCapabilities::default() + }, + ..FakeLspAdapter::default() + }, + ); + + let buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/project/file.rs"), cx) + }) + .await + .unwrap(); + project + .update(cx, |project, cx| { + project.open_unstaged_diff(buffer.clone(), cx) + }) + .await + .unwrap(); + + let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + let (editor, cx) = cx.add_window_view(|window, cx| { + build_editor_with_project(project.clone(), buffer, window, cx) + }); + editor.update_in(cx, |editor, window, cx| { + window.focus(&editor.focus_handle(cx), cx); + }); + + let fake_server = fake_servers.next().await.unwrap(); + + update_test_language_settings(cx, &|settings| { + settings.defaults.format_on_save = Some(FormatOnSave::ModificationsIfAvailable); + }); + + editor.update_in(cx, |editor, window, cx| { + editor.set_text("line0\nLINE1\nline2\n", window, cx); + }); + cx.run_until_parked(); + assert!(cx.read(|cx| editor.is_dirty(cx))); + + let _no_range_format = fake_server + .set_request_handler::(move |_, _| async move { + panic!("rangeFormatting must not be called when LSP lacks range support"); + }) + .next(); + + let formatting_called = Arc::new(AtomicBool::new(false)); + let formatting_called_clone = formatting_called.clone(); + let mut formatting_rx = + fake_server.set_request_handler::(move |_, _| { + formatting_called_clone.store(true, atomic::Ordering::SeqCst); + async move { Ok(Some(Vec::new())) } + }); + + let save = editor + .update_in(cx, |editor, window, cx| { + editor.save( + SaveOptions { + format: true, + autosave: false, + force_format: false, + }, + project.clone(), + window, + cx, + ) + }) + .unwrap(); + formatting_rx.next().await; + save.await; + + assert!( + formatting_called.load(atomic::Ordering::SeqCst), + "ModificationsIfAvailable must fall back to full-file formatting when the LSP lacks range support" + ); +} + +#[gpui::test] +async fn test_range_format_not_called_for_clean_buffer(cx: &mut TestAppContext) { + let (project, editor, cx, fake_server) = setup_range_format_test(cx).await; + + // Buffer starts clean, no formatting should be requested + let save = editor + .update_in(cx, |editor, window, cx| { + editor.save( + SaveOptions { + format: false, + force_format: false, + autosave: false, + }, + project.clone(), + window, + cx, + ) + }) + .unwrap(); + let _pending_format_request = fake_server + .set_request_handler::(move |_, _| async move { + panic!("Should not be invoked"); + }) + .next(); + save.await; + cx.run_until_parked(); +} + +#[gpui::test] +async fn test_range_format_respects_language_tab_size_override(cx: &mut TestAppContext) { + let (project, editor, cx, fake_server) = setup_range_format_test(cx).await; + + // Set Rust language override and assert overridden tabsize is sent to language server + update_test_language_settings(cx, &|settings| { + settings.languages.0.insert( + "Rust".into(), + LanguageSettingsContent { + tab_size: NonZeroU32::new(8), + ..Default::default() + }, + ); + }); + + editor.update_in(cx, |editor, window, cx| { + editor.set_text("something_new\n", window, cx) + }); + assert!(cx.read(|cx| editor.is_dirty(cx))); + let save = editor + .update_in(cx, |editor, window, cx| { + editor.save( + SaveOptions { + format: true, + force_format: false, + autosave: false, + }, + project.clone(), + window, + cx, + ) + }) + .unwrap(); + fake_server + .set_request_handler::(move |params, _| async move { + assert_eq!( + params.text_document.uri, + lsp::Uri::from_file_path(path!("/file.rs")).unwrap() + ); + assert_eq!(params.options.tab_size, 8); + Ok(Some(Vec::new())) + }) + .next() + .await; + save.await; +} + +#[gpui::test] +async fn test_document_format_manual_trigger(cx: &mut TestAppContext) { + init_test(cx, |settings| { + settings.defaults.formatter = Some(FormatterList::Single(Formatter::LanguageServer( + settings::LanguageServerFormatterSpecifier::Current, + ))) + }); + + let fs = FakeFs::new(cx.executor()); + fs.insert_file(path!("/file.rs"), Default::default()).await; + + let project = Project::test(fs, [path!("/").as_ref()], cx).await; + + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(Arc::new(Language::new( + LanguageConfig { + name: "Rust".into(), + matcher: (LanguageMatcher { + path_suffixes: vec!["rs".to_string()], + ..Default::default() + }) + .into(), + ..LanguageConfig::default() + }, + Some(tree_sitter_rust::LANGUAGE.into()), + ))); + update_test_language_settings(cx, &|settings| { + // Enable Prettier formatting for the same buffer, and ensure + // LSP is called instead of Prettier. + settings.defaults.prettier.get_or_insert_default().allowed = Some(true); + }); + let mut fake_servers = language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + capabilities: lsp::ServerCapabilities { + document_formatting_provider: Some(lsp::OneOf::Left(true)), + ..Default::default() + }, + ..Default::default() + }, + ); + + let buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/file.rs"), cx) + }) + .await + .unwrap(); + + let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + let (editor, cx) = cx.add_window_view(|window, cx| { + build_editor_with_project(project.clone(), buffer, window, cx) + }); + editor.update_in(cx, |editor, window, cx| { + editor.set_text("one\ntwo\nthree\n", window, cx) + }); + + let fake_server = fake_servers.next().await.unwrap(); + + let format = editor + .update_in(cx, |editor, window, cx| { + editor.perform_format( + project.clone(), + FormatTrigger::Manual, + FormatTarget::Buffers(editor.buffer().read(cx).all_buffers()), + window, + cx, + ) + }) + .unwrap(); + fake_server + .set_request_handler::(move |params, _| async move { + assert_eq!( + params.text_document.uri, + lsp::Uri::from_file_path(path!("/file.rs")).unwrap() + ); + assert_eq!(params.options.tab_size, 4); + Ok(Some(vec![lsp::TextEdit::new( + lsp::Range::new(lsp::Position::new(0, 3), lsp::Position::new(1, 0)), + ", ".to_string(), + )])) + }) + .next() + .await; + format.await; + assert_eq!( + editor.update(cx, |editor, cx| editor.text(cx)), + "one, two\nthree\n" + ); + + editor.update_in(cx, |editor, window, cx| { + editor.set_text("one\ntwo\nthree\n", window, cx) + }); + // Ensure we don't lock if formatting hangs. + fake_server.set_request_handler::( + move |params, _| async move { + assert_eq!( + params.text_document.uri, + lsp::Uri::from_file_path(path!("/file.rs")).unwrap() + ); + futures::future::pending::<()>().await; + unreachable!() + }, + ); + let format = editor + .update_in(cx, |editor, window, cx| { + editor.perform_format( + project, + FormatTrigger::Manual, + FormatTarget::Buffers(editor.buffer().read(cx).all_buffers()), + window, + cx, + ) + }) + .unwrap(); + cx.executor().advance_clock(super::FORMAT_TIMEOUT); + format.await; + assert_eq!( + editor.update(cx, |editor, cx| editor.text(cx)), + "one\ntwo\nthree\n" + ); +} + +#[gpui::test] +async fn test_multiple_formatters(cx: &mut TestAppContext) { + init_test(cx, |settings| { + settings.defaults.remove_trailing_whitespace_on_save = Some(true); + settings.defaults.formatter = Some(FormatterList::Vec(vec![ + Formatter::LanguageServer(settings::LanguageServerFormatterSpecifier::Current), + Formatter::CodeAction("code-action-1".into()), + Formatter::CodeAction("code-action-2".into()), + ])) + }); + + let fs = FakeFs::new(cx.executor()); + fs.insert_file(path!("/file.rs"), "one \ntwo \nthree".into()) + .await; + + let project = Project::test(fs, [path!("/").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(rust_lang()); + + let mut fake_servers = language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + capabilities: lsp::ServerCapabilities { + document_formatting_provider: Some(lsp::OneOf::Left(true)), + execute_command_provider: Some(lsp::ExecuteCommandOptions { + commands: vec!["the-command-for-code-action-1".into()], + ..Default::default() + }), + code_action_provider: Some(lsp::CodeActionProviderCapability::Simple(true)), + ..Default::default() + }, + ..Default::default() + }, + ); + + let buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/file.rs"), cx) + }) + .await + .unwrap(); + + let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + let (editor, cx) = cx.add_window_view(|window, cx| { + build_editor_with_project(project.clone(), buffer, window, cx) + }); + + let fake_server = fake_servers.next().await.unwrap(); + fake_server.set_request_handler::( + move |_params, _| async move { + Ok(Some(vec![lsp::TextEdit::new( + lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 0)), + "applied-formatting\n".to_string(), + )])) + }, + ); + fake_server.set_request_handler::( + move |params, _| async move { + let requested_code_actions = params.context.only.expect("Expected code action request"); + assert_eq!(requested_code_actions.len(), 1); + + let uri = lsp::Uri::from_file_path(path!("/file.rs")).unwrap(); + let code_action = match requested_code_actions[0].as_str() { + "code-action-1" => lsp::CodeAction { + kind: Some("code-action-1".into()), + edit: Some(lsp::WorkspaceEdit::new( + [( + uri, + vec![lsp::TextEdit::new( + lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 0)), + "applied-code-action-1-edit\n".to_string(), + )], + )] + .into_iter() + .collect(), + )), + command: Some(lsp::Command { + command: "the-command-for-code-action-1".into(), + ..Default::default() + }), + ..Default::default() + }, + "code-action-2" => lsp::CodeAction { + kind: Some("code-action-2".into()), + edit: Some(lsp::WorkspaceEdit::new( + [( + uri, + vec![lsp::TextEdit::new( + lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 0)), + "applied-code-action-2-edit\n".to_string(), + )], + )] + .into_iter() + .collect(), + )), + ..Default::default() + }, + req => panic!("Unexpected code action request: {:?}", req), + }; + Ok(Some(vec![lsp::CodeActionOrCommand::CodeAction( + code_action, + )])) + }, + ); + + fake_server.set_request_handler::({ + move |params, _| async move { Ok(params) } + }); + + let command_lock = Arc::new(futures::lock::Mutex::new(())); + fake_server.set_request_handler::({ + let fake = fake_server.clone(); + let lock = command_lock.clone(); + move |params, _| { + assert_eq!(params.command, "the-command-for-code-action-1"); + let fake = fake.clone(); + let lock = lock.clone(); + async move { + lock.lock().await; + fake.server + .request::( + lsp::ApplyWorkspaceEditParams { + label: None, + edit: lsp::WorkspaceEdit { + changes: Some( + [( + lsp::Uri::from_file_path(path!("/file.rs")).unwrap(), + vec![lsp::TextEdit { + range: lsp::Range::new( + lsp::Position::new(0, 0), + lsp::Position::new(0, 0), + ), + new_text: "applied-code-action-1-command\n".into(), + }], + )] + .into_iter() + .collect(), + ), + ..Default::default() + }, + }, + DEFAULT_LSP_REQUEST_TIMEOUT, + ) + .await + .into_response() + .unwrap(); + Ok(Some(json!(null))) + } + } + }); + + editor + .update_in(cx, |editor, window, cx| { + editor.perform_format( + project.clone(), + FormatTrigger::Manual, + FormatTarget::Buffers(editor.buffer().read(cx).all_buffers()), + window, + cx, + ) + }) + .unwrap() + .await; + editor.update(cx, |editor, cx| { + assert_eq!( + editor.text(cx), + r#" + applied-code-action-2-edit + applied-code-action-1-command + applied-code-action-1-edit + applied-formatting + one + two + three + "# + .unindent() + ); + }); + + editor.update_in(cx, |editor, window, cx| { + editor.undo(&Default::default(), window, cx); + assert_eq!(editor.text(cx), "one \ntwo \nthree"); + }); + + // Perform a manual edit while waiting for an LSP command + // that's being run as part of a formatting code action. + let lock_guard = command_lock.lock().await; + let format = editor + .update_in(cx, |editor, window, cx| { + editor.perform_format( + project.clone(), + FormatTrigger::Manual, + FormatTarget::Buffers(editor.buffer().read(cx).all_buffers()), + window, + cx, + ) + }) + .unwrap(); + cx.run_until_parked(); + editor.update(cx, |editor, cx| { + assert_eq!( + editor.text(cx), + r#" + applied-code-action-1-edit + applied-formatting + one + two + three + "# + .unindent() + ); + + editor.buffer.update(cx, |buffer, cx| { + let ix = buffer.len(cx); + buffer.edit([(ix..ix, "edited\n")], None, cx); + }); + }); + + // Allow the LSP command to proceed. Because the buffer was edited, + // the second code action will not be run. + drop(lock_guard); + format.await; + editor.update_in(cx, |editor, window, cx| { + assert_eq!( + editor.text(cx), + r#" + applied-code-action-1-command + applied-code-action-1-edit + applied-formatting + one + two + three + edited + "# + .unindent() + ); + + // The manual edit is undone first, because it is the last thing the user did + // (even though the command completed afterwards). + editor.undo(&Default::default(), window, cx); + assert_eq!( + editor.text(cx), + r#" + applied-code-action-1-command + applied-code-action-1-edit + applied-formatting + one + two + three + "# + .unindent() + ); + + // All the formatting (including the command, which completed after the manual edit) + // is undone together. + editor.undo(&Default::default(), window, cx); + assert_eq!(editor.text(cx), "one \ntwo \nthree"); + }); +} + +#[gpui::test] +async fn test_markdown_save_respects_disabled_trailing_whitespace_removal(cx: &mut TestAppContext) { + init_test(cx, |settings| { + settings.defaults.ensure_final_newline_on_save = Some(false); + settings.languages.0.insert( + "Markdown".into(), + LanguageSettingsContent { + remove_trailing_whitespace_on_save: Some(false), + ..Default::default() + }, + ); + }); + + let fs = FakeFs::new(cx.executor()); + fs.insert_file( + path!("/.editorconfig"), + "root = true\n[*]\ntrim_trailing_whitespace = true\n".into(), + ) + .await; + fs.insert_file(path!("/README.md"), "".into()).await; + + let project = Project::test(fs.clone(), [path!("/").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(markdown_lang()); + + let buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/README.md"), cx) + }) + .await + .unwrap(); + + let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + let (editor, cx) = cx.add_window_view(|window, cx| { + build_editor_with_project(project.clone(), buffer, window, cx) + }); + editor.update_in(cx, |editor, window, cx| { + editor.set_text("line with spaces \n", window, cx) + }); + + let save = editor + .update_in(cx, |editor, window, cx| { + editor.save( + SaveOptions { + format: true, + force_format: false, + autosave: false, + }, + project.clone(), + window, + cx, + ) + }) + .unwrap(); + save.await; + + assert_eq!( + editor.update(cx, |editor, cx| editor.text(cx)), + "line with spaces \n" + ); + assert_eq!( + fs.load(path!("/README.md").as_ref()) + .await + .unwrap() + .replace("\r\n", "\n"), + "line with spaces \n" + ); +} + +#[gpui::test] +async fn test_organize_imports_manual_trigger(cx: &mut TestAppContext) { + init_test(cx, |settings| { + settings.defaults.formatter = Some(FormatterList::Vec(vec![Formatter::LanguageServer( + settings::LanguageServerFormatterSpecifier::Current, + )])) + }); + + let fs = FakeFs::new(cx.executor()); + fs.insert_file(path!("/file.ts"), Default::default()).await; + + let project = Project::test(fs, [path!("/").as_ref()], cx).await; + + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(Arc::new(Language::new( + LanguageConfig { + name: "TypeScript".into(), + matcher: (LanguageMatcher { + path_suffixes: vec!["ts".to_string()], + ..Default::default() + }) + .into(), + ..LanguageConfig::default() + }, + Some(tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into()), + ))); + update_test_language_settings(cx, &|settings| { + settings.defaults.prettier.get_or_insert_default().allowed = Some(true); + }); + let mut fake_servers = language_registry.register_fake_lsp( + "TypeScript", + FakeLspAdapter { + capabilities: lsp::ServerCapabilities { + code_action_provider: Some(lsp::CodeActionProviderCapability::Simple(true)), + ..Default::default() + }, + ..Default::default() + }, + ); + + let buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/file.ts"), cx) + }) + .await + .unwrap(); + + let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + let (editor, cx) = cx.add_window_view(|window, cx| { + build_editor_with_project(project.clone(), buffer, window, cx) + }); + editor.update_in(cx, |editor, window, cx| { + editor.set_text( + "import { a } from 'module';\nimport { b } from 'module';\n\nconst x = a;\n", + window, + cx, + ) + }); + + let fake_server = fake_servers.next().await.unwrap(); + + let format = editor + .update_in(cx, |editor, window, cx| { + editor.perform_code_action_kind( + project.clone(), + CodeActionKind::SOURCE_ORGANIZE_IMPORTS, + window, + cx, + ) + }) + .unwrap(); + fake_server + .set_request_handler::(move |params, _| async move { + assert_eq!( + params.text_document.uri, + lsp::Uri::from_file_path(path!("/file.ts")).unwrap() + ); + Ok(Some(vec![lsp::CodeActionOrCommand::CodeAction( + lsp::CodeAction { + title: "Organize Imports".to_string(), + kind: Some(lsp::CodeActionKind::SOURCE_ORGANIZE_IMPORTS), + edit: Some(lsp::WorkspaceEdit { + changes: Some( + [( + params.text_document.uri.clone(), + vec![lsp::TextEdit::new( + lsp::Range::new( + lsp::Position::new(1, 0), + lsp::Position::new(2, 0), + ), + "".to_string(), + )], + )] + .into_iter() + .collect(), + ), + ..Default::default() + }), + ..Default::default() + }, + )])) + }) + .next() + .await; + format.await; + assert_eq!( + editor.update(cx, |editor, cx| editor.text(cx)), + "import { a } from 'module';\n\nconst x = a;\n" + ); + + editor.update_in(cx, |editor, window, cx| { + editor.set_text( + "import { a } from 'module';\nimport { b } from 'module';\n\nconst x = a;\n", + window, + cx, + ) + }); + // Ensure we don't lock if code action hangs. + fake_server.set_request_handler::( + move |params, _| async move { + assert_eq!( + params.text_document.uri, + lsp::Uri::from_file_path(path!("/file.ts")).unwrap() + ); + futures::future::pending::<()>().await; + unreachable!() + }, + ); + let format = editor + .update_in(cx, |editor, window, cx| { + editor.perform_code_action_kind( + project, + CodeActionKind::SOURCE_ORGANIZE_IMPORTS, + window, + cx, + ) + }) + .unwrap(); + cx.executor().advance_clock(super::CODE_ACTION_TIMEOUT); + format.await; + assert_eq!( + editor.update(cx, |editor, cx| editor.text(cx)), + "import { a } from 'module';\nimport { b } from 'module';\n\nconst x = a;\n" + ); +} + +#[gpui::test] +async fn test_formatter_failure_does_not_abort_subsequent_formatters(cx: &mut TestAppContext) { + init_test(cx, |settings| { + settings.defaults.formatter = Some(FormatterList::Vec(vec![ + Formatter::LanguageServer(settings::LanguageServerFormatterSpecifier::Current), + Formatter::CodeAction("organize-imports".into()), + ])) + }); + + let fs = FakeFs::new(cx.executor()); + fs.insert_file(path!("/file.rs"), "fn main() {}\n".into()) + .await; + + let project = Project::test(fs, [path!("/").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(rust_lang()); + + let mut fake_servers = language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + capabilities: lsp::ServerCapabilities { + document_formatting_provider: Some(lsp::OneOf::Left(true)), + code_action_provider: Some(lsp::CodeActionProviderCapability::Simple(true)), + ..Default::default() + }, + ..Default::default() + }, + ); + + let buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/file.rs"), cx) + }) + .await + .unwrap(); + + let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + let (editor, cx) = cx.add_window_view(|window, cx| { + build_editor_with_project(project.clone(), buffer, window, cx) + }); + + let fake_server = fake_servers.next().await.unwrap(); + + // Formatter #1 (LanguageServer) returns an error to simulate failure + fake_server.set_request_handler::( + move |_params, _| async move { Err(anyhow::anyhow!("Simulated formatter failure")) }, + ); + + // Formatter #2 (CodeAction) returns a successful edit + fake_server.set_request_handler::( + move |_params, _| async move { + let uri = lsp::Uri::from_file_path(path!("/file.rs")).unwrap(); + Ok(Some(vec![lsp::CodeActionOrCommand::CodeAction( + lsp::CodeAction { + kind: Some("organize-imports".into()), + edit: Some(lsp::WorkspaceEdit::new( + [( + uri, + vec![lsp::TextEdit::new( + lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 0)), + "use std::io;\n".to_string(), + )], + )] + .into_iter() + .collect(), + )), + ..Default::default() + }, + )])) + }, + ); + + fake_server.set_request_handler::({ + move |params, _| async move { Ok(params) } + }); + + editor + .update_in(cx, |editor, window, cx| { + editor.perform_format( + project.clone(), + FormatTrigger::Manual, + FormatTarget::Buffers(editor.buffer().read(cx).all_buffers()), + window, + cx, + ) + }) + .unwrap() + .await; + + // Formatter #1 (LanguageServer) failed, but formatter #2 (CodeAction) should have applied + editor.update(cx, |editor, cx| { + assert_eq!(editor.text(cx), "use std::io;\nfn main() {}\n"); + }); + + // The entire format operation should undo as one transaction + editor.update_in(cx, |editor, window, cx| { + editor.undo(&Default::default(), window, cx); + assert_eq!(editor.text(cx), "fn main() {}\n"); + }); +} + +#[gpui::test] +async fn test_explicit_formatter_failure_is_recorded(cx: &mut TestAppContext) { + init_test(cx, |settings| { + settings.defaults.formatter = Some(FormatterList::Vec(vec![Formatter::LanguageServer( + settings::LanguageServerFormatterSpecifier::Current, + )])) + }); + + let fs = FakeFs::new(cx.executor()); + fs.insert_file(path!("/file.rs"), "fn main() {}\n".into()) + .await; + + let project = Project::test(fs, [path!("/").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(rust_lang()); + + let format_should_fail = Arc::new(AtomicBool::new(true)); + let mut fake_servers = language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + capabilities: lsp::ServerCapabilities { + document_formatting_provider: Some(lsp::OneOf::Left(true)), + ..lsp::ServerCapabilities::default() + }, + initializer: Some(Box::new({ + let format_should_fail = format_should_fail.clone(); + move |fake_server| { + let format_should_fail = format_should_fail.clone(); + fake_server.set_request_handler::( + move |_params, _| { + let format_should_fail = format_should_fail.clone(); + async move { + if format_should_fail.load(atomic::Ordering::Acquire) { + Err(anyhow::anyhow!("Simulated formatter failure")) + } else { + Ok(Some(Vec::new())) + } + } + }, + ); + } + })), + ..FakeLspAdapter::default() + }, + ); + + let buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/file.rs"), cx) + }) + .await + .unwrap(); + + let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + let (editor, cx) = cx.add_window_view(|window, cx| { + build_editor_with_project(project.clone(), buffer, window, cx) + }); + + fake_servers.next().await.unwrap(); + + let format = |editor: &Entity, cx: &mut VisualTestContext| { + editor + .update_in(cx, |editor, window, cx| { + editor.perform_format( + project.clone(), + FormatTrigger::Manual, + FormatTarget::Buffers(editor.buffer().read(cx).all_buffers()), + window, + cx, + ) + }) + .unwrap() + }; + let last_failure = |cx: &mut VisualTestContext| { + project.read_with(cx, |project, cx| { + project + .lsp_store() + .read(cx) + .last_formatting_failure() + .map(str::to_string) + }) + }; + + format(&editor, cx).await; + assert!( + last_failure(cx).is_some(), + "a failing explicitly configured formatter should be recorded" + ); + + format_should_fail.store(false, atomic::Ordering::Release); + format(&editor, cx).await; + assert_eq!( + last_failure(cx), + None, + "A later successful format clears the recorded failure" + ); +} + +#[gpui::test] +async fn test_auto_formatter_failure_is_silent(cx: &mut TestAppContext) { + init_test(cx, |settings| { + // `formatter: "auto"` with prettier disallowed resolves to the primary language server. + settings.defaults.formatter = Some(FormatterList::Single(Formatter::Auto)); + settings.defaults.prettier.get_or_insert_default().allowed = Some(false); + }); + + let fs = FakeFs::new(cx.executor()); + fs.insert_file(path!("/file.rs"), "fn main() {}\n".into()) + .await; + + let project = Project::test(fs, [path!("/").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(rust_lang()); + + let mut fake_servers = language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + capabilities: lsp::ServerCapabilities { + document_formatting_provider: Some(lsp::OneOf::Left(true)), + ..lsp::ServerCapabilities::default() + }, + initializer: Some(Box::new(|fake_server| { + fake_server.set_request_handler::( + move |_params, _| async move { + Err(anyhow::anyhow!("Simulated formatter failure")) + }, + ); + })), + ..FakeLspAdapter::default() + }, + ); + + let buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/file.rs"), cx) + }) + .await + .unwrap(); + + let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + let (editor, cx) = cx.add_window_view(|window, cx| { + build_editor_with_project(project.clone(), buffer, window, cx) + }); + + fake_servers.next().await.unwrap(); + + editor + .update_in(cx, |editor, window, cx| { + editor.perform_format( + project.clone(), + FormatTrigger::Manual, + FormatTarget::Buffers(editor.buffer().read(cx).all_buffers()), + window, + cx, + ) + }) + .unwrap() + .await; + + let last_failure = project.read_with(cx, |project, cx| { + project + .lsp_store() + .read(cx) + .last_formatting_failure() + .map(str::to_string) + }); + assert_eq!( + last_failure, None, + "an auto-resolved formatter failure should not be surfaced" + ); +} + +#[gpui::test] +async fn test_external_formatter_with_no_output_leaves_buffer_unchanged(cx: &mut TestAppContext) { + init_test(cx, |settings| { + #[cfg(windows)] + let (command, arguments) = ("cmd", vec!["/C".to_string(), "more > nul".to_string()]); + #[cfg(not(windows))] + let (command, arguments) = ("sh", vec!["-c".to_string(), "cat >/dev/null".to_string()]); + + // Consume stdin without printing anything, mirroring tools like `cargo fmt` + // that format files in place instead of writing to stdout. + settings.defaults.formatter = Some(FormatterList::Single(Formatter::External { + command: command.into(), + arguments: Some(arguments), + })); + }); + + let fs = FakeFs::new(cx.executor()); + fs.insert_file(path!("/file.rs"), "fn main() {}\n".into()) + .await; + + let project = Project::test(fs, [path!("/").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(rust_lang()); + + let buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/file.rs"), cx) + }) + .await + .unwrap(); + + let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + let (editor, cx) = cx.add_window_view(|window, cx| { + build_editor_with_project(project.clone(), buffer, window, cx) + }); + + // Formatting via an external command spawns a real subprocess, whose I/O + // happens off the deterministic test scheduler. + cx.executor().allow_parking(); + + editor + .update_in(cx, |editor, window, cx| { + editor.perform_format( + project.clone(), + FormatTrigger::Manual, + FormatTarget::Buffers(editor.buffer().read(cx).all_buffers()), + window, + cx, + ) + }) + .unwrap() + .await; + + editor.update(cx, |editor, cx| { + assert_eq!( + editor.text(cx), + "fn main() {}\n", + "buffer should be left unchanged when the external formatter produces no output" + ); + }); + + let last_failure = project.read_with(cx, |project, cx| { + project + .lsp_store() + .read(cx) + .last_formatting_failure() + .map(str::to_string) + }); + assert_eq!( + last_failure, None, + "producing no output is not a formatter failure" + ); +} + +#[gpui::test] +async fn test_concurrent_format_requests(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + document_formatting_provider: Some(lsp::OneOf::Left(true)), + ..Default::default() + }, + cx, + ) + .await; + + cx.set_state(indoc! {" + one.twoˇ + "}); + + // The format request takes a long time. When it completes, it inserts + // a newline and an indent before the `.` + cx.lsp + .set_request_handler::(move |_, cx| { + let executor = cx.background_executor().clone(); + async move { + executor.timer(Duration::from_millis(100)).await; + Ok(Some(vec![lsp::TextEdit { + range: lsp::Range::new(lsp::Position::new(0, 3), lsp::Position::new(0, 3)), + new_text: "\n ".into(), + }])) + } + }); + + // Submit a format request. + let format_1 = cx + .update_editor(|editor, window, cx| editor.format(&Format, window, cx)) + .unwrap(); + cx.executor().run_until_parked(); + + // Submit a second format request. + let format_2 = cx + .update_editor(|editor, window, cx| editor.format(&Format, window, cx)) + .unwrap(); + cx.executor().run_until_parked(); + + // Wait for both format requests to complete + cx.executor().advance_clock(Duration::from_millis(200)); + format_1.await.unwrap(); + format_2.await.unwrap(); + + // The formatting edits only happens once. + cx.assert_editor_state(indoc! {" + one + .twoˇ + "}); +} + +#[gpui::test] +async fn test_strip_whitespace_and_format_via_lsp(cx: &mut TestAppContext) { + init_test(cx, |settings| { + settings.defaults.formatter = Some(FormatterList::default()) + }); + + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + document_formatting_provider: Some(lsp::OneOf::Left(true)), + ..Default::default() + }, + cx, + ) + .await; + + // Record which buffer changes have been sent to the language server + let buffer_changes = Arc::new(Mutex::new(Vec::new())); + cx.lsp + .handle_notification::({ + let buffer_changes = buffer_changes.clone(); + move |params, _| { + buffer_changes.lock().extend( + params + .content_changes + .into_iter() + .map(|e| (e.range.unwrap(), e.text)), + ); + } + }); + // Handle formatting requests to the language server. + cx.lsp + .set_request_handler::({ + move |_, _| { + // Insert blank lines between each line of the buffer. + async move { + // TODO: this assertion is not reliably true. Currently nothing guarantees that we deliver + // DidChangedTextDocument to the LSP before sending the formatting request. + // assert_eq!( + // &buffer_changes.lock()[1..], + // &[ + // ( + // lsp::Range::new(lsp::Position::new(0, 3), lsp::Position::new(0, 4)), + // "".into() + // ), + // ( + // lsp::Range::new(lsp::Position::new(2, 5), lsp::Position::new(2, 6)), + // "".into() + // ), + // ( + // lsp::Range::new(lsp::Position::new(3, 4), lsp::Position::new(3, 4)), + // "\n".into() + // ), + // ] + // ); + + Ok(Some(vec![ + lsp::TextEdit { + range: lsp::Range::new( + lsp::Position::new(1, 0), + lsp::Position::new(1, 0), + ), + new_text: "\n".into(), + }, + lsp::TextEdit { + range: lsp::Range::new( + lsp::Position::new(2, 0), + lsp::Position::new(2, 0), + ), + new_text: "\n".into(), + }, + ])) + } + } + }); + + // Set up a buffer white some trailing whitespace and no trailing newline. + cx.set_state( + &[ + "one ", // + "twoˇ", // + "three ", // + "four", // + ] + .join("\n"), + ); + + // Submit a format request. + let format = cx + .update_editor(|editor, window, cx| editor.format(&Format, window, cx)) + .unwrap(); + + cx.run_until_parked(); + // After formatting the buffer, the trailing whitespace is stripped, + // a newline is appended, and the edits provided by the language server + // have been applied. + format.await.unwrap(); + + cx.assert_editor_state( + &[ + "one", // + "", // + "twoˇ", // + "", // + "three", // + "four", // + "", // + ] + .join("\n"), + ); + + // Undoing the formatting undoes the trailing whitespace removal, the + // trailing newline, and the LSP edits. + cx.update_buffer(|buffer, cx| buffer.undo(cx)); + cx.assert_editor_state( + &[ + "one ", // + "twoˇ", // + "three ", // + "four", // + ] + .join("\n"), + ); +} + +#[gpui::test] +async fn test_handle_input_for_show_signature_help_auto_signature_help_true( + cx: &mut TestAppContext, +) { + init_test(cx, |_| {}); + + cx.update(|cx| { + cx.update_global::(|settings, cx| { + settings.update_user_settings(cx, |settings| { + settings.editor.auto_signature_help = Some(true); + settings.editor.hover_popover_delay = Some(DelayMs(300)); + }); + }); + }); + + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + signature_help_provider: Some(lsp::SignatureHelpOptions { + ..Default::default() + }), + ..Default::default() + }, + cx, + ) + .await; + + let language = Language::new( + LanguageConfig { + name: "Rust".into(), + brackets: BracketPairConfig { + pairs: vec![ + BracketPair { + start: "{".to_string(), + end: "}".to_string(), + close: true, + surround: true, + newline: true, + }, + BracketPair { + start: "(".to_string(), + end: ")".to_string(), + close: true, + surround: true, + newline: true, + }, + BracketPair { + start: "/*".to_string(), + end: " */".to_string(), + close: true, + surround: true, + newline: true, + }, + BracketPair { + start: "[".to_string(), + end: "]".to_string(), + close: false, + surround: false, + newline: true, + }, + BracketPair { + start: "\"".to_string(), + end: "\"".to_string(), + close: true, + surround: true, + newline: false, + }, + BracketPair { + start: "<".to_string(), + end: ">".to_string(), + close: false, + surround: true, + newline: true, + }, + ], + ..Default::default() + }, + autoclose_before: "})]".to_string(), + ..Default::default() + }, + Some(tree_sitter_rust::LANGUAGE.into()), + ); + let language = Arc::new(language); + + cx.language_registry().add(language.clone()); + cx.update_buffer(|buffer, cx| { + buffer.set_language(Some(language), cx); + }); + + cx.set_state( + &r#" + fn main() { + sampleˇ + } + "# + .unindent(), + ); + + cx.update_editor(|editor, window, cx| { + editor.handle_input("(", window, cx); + }); + cx.assert_editor_state( + &" + fn main() { + sample(ˇ) + } + " + .unindent(), + ); + + let mocked_response = lsp::SignatureHelp { + signatures: vec![lsp::SignatureInformation { + label: "fn sample(param1: u8, param2: u8)".to_string(), + documentation: None, + parameters: Some(vec![ + lsp::ParameterInformation { + label: lsp::ParameterLabel::Simple("param1: u8".to_string()), + documentation: None, + }, + lsp::ParameterInformation { + label: lsp::ParameterLabel::Simple("param2: u8".to_string()), + documentation: None, + }, + ]), + active_parameter: None, + }], + active_signature: Some(0), + active_parameter: Some(0), + }; + handle_signature_help_request(&mut cx, mocked_response).await; + + cx.condition(|editor, _| editor.signature_help_state.is_shown()) + .await; + + cx.editor(|editor, _, _| { + let signature_help_state = editor.signature_help_state.popover().cloned(); + let signature = signature_help_state.unwrap(); + assert_eq!( + signature.signatures[signature.current_signature].label, + "fn sample(param1: u8, param2: u8)" + ); + }); +} + +#[gpui::test] +async fn test_signature_help_delay_only_for_auto(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let delay_ms = 500; + cx.update(|cx| { + cx.update_global::(|settings, cx| { + settings.update_user_settings(cx, |settings| { + settings.editor.auto_signature_help = Some(true); + settings.editor.show_signature_help_after_edits = Some(false); + settings.editor.hover_popover_delay = Some(DelayMs(delay_ms)); + }); + }); + }); + + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + signature_help_provider: Some(lsp::SignatureHelpOptions::default()), + ..lsp::ServerCapabilities::default() + }, + cx, + ) + .await; + + let mocked_response = lsp::SignatureHelp { + signatures: vec![lsp::SignatureInformation { + label: "fn sample(param1: u8)".to_string(), + documentation: None, + parameters: Some(vec![lsp::ParameterInformation { + label: lsp::ParameterLabel::Simple("param1: u8".to_string()), + documentation: None, + }]), + active_parameter: None, + }], + active_signature: Some(0), + active_parameter: Some(0), + }; + + cx.set_state(indoc! {" + fn main() { + sample(ˇ); + } + + fn sample(param1: u8) {} + "}); + + // Manual trigger should show immediately without delay + cx.update_editor(|editor, window, cx| { + editor.show_signature_help(&ShowSignatureHelp, window, cx); + }); + handle_signature_help_request(&mut cx, mocked_response.clone()).await; + cx.run_until_parked(); + cx.editor(|editor, _, _| { + assert!( + editor.signature_help_state.is_shown(), + "Manual trigger should show signature help without delay" + ); + }); + + cx.update_editor(|editor, _, cx| { + editor.hide_signature_help(cx, SignatureHelpHiddenBy::Escape); + }); + cx.run_until_parked(); + cx.editor(|editor, _, _| { + assert!(!editor.signature_help_state.is_shown()); + }); + + // Auto trigger (cursor movement into brackets) should respect delay + cx.set_state(indoc! {" + fn main() { + sampleˇ(); + } + + fn sample(param1: u8) {} + "}); + cx.update_editor(|editor, window, cx| { + editor.move_right(&MoveRight, window, cx); + }); + handle_signature_help_request(&mut cx, mocked_response.clone()).await; + cx.run_until_parked(); + cx.editor(|editor, _, _| { + assert!( + !editor.signature_help_state.is_shown(), + "Auto trigger should wait for delay before showing signature help" + ); + }); + + cx.executor() + .advance_clock(Duration::from_millis(delay_ms + 50)); + cx.run_until_parked(); + cx.editor(|editor, _, _| { + assert!( + editor.signature_help_state.is_shown(), + "Auto trigger should show signature help after delay elapsed" + ); + }); +} + +#[gpui::test] +async fn test_signature_help_after_edits_no_delay(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let delay_ms = 500; + cx.update(|cx| { + cx.update_global::(|settings, cx| { + settings.update_user_settings(cx, |settings| { + settings.editor.auto_signature_help = Some(false); + settings.editor.show_signature_help_after_edits = Some(true); + settings.editor.hover_popover_delay = Some(DelayMs(delay_ms)); + }); + }); + }); + + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + signature_help_provider: Some(lsp::SignatureHelpOptions::default()), + ..lsp::ServerCapabilities::default() + }, + cx, + ) + .await; + + let language = Arc::new(Language::new( + LanguageConfig { + name: "Rust".into(), + brackets: BracketPairConfig { + pairs: vec![BracketPair { + start: "(".to_string(), + end: ")".to_string(), + close: true, + surround: true, + newline: true, + }], + ..BracketPairConfig::default() + }, + autoclose_before: "})".to_string(), + ..LanguageConfig::default() + }, + Some(tree_sitter_rust::LANGUAGE.into()), + )); + cx.language_registry().add(language.clone()); + cx.update_buffer(|buffer, cx| { + buffer.set_language(Some(language), cx); + }); + + let mocked_response = lsp::SignatureHelp { + signatures: vec![lsp::SignatureInformation { + label: "fn sample(param1: u8)".to_string(), + documentation: None, + parameters: Some(vec![lsp::ParameterInformation { + label: lsp::ParameterLabel::Simple("param1: u8".to_string()), + documentation: None, + }]), + active_parameter: None, + }], + active_signature: Some(0), + active_parameter: Some(0), + }; + + cx.set_state(indoc! {" + fn main() { + sampleˇ + } + "}); + + // Typing bracket should show signature help immediately without delay + cx.update_editor(|editor, window, cx| { + editor.handle_input("(", window, cx); + }); + handle_signature_help_request(&mut cx, mocked_response).await; + cx.run_until_parked(); + cx.editor(|editor, _, _| { + assert!( + editor.signature_help_state.is_shown(), + "show_signature_help_after_edits should show signature help without delay" + ); + }); +} + +#[gpui::test] +async fn test_handle_input_with_different_show_signature_settings(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + cx.update(|cx| { + cx.update_global::(|settings, cx| { + settings.update_user_settings(cx, |settings| { + settings.editor.auto_signature_help = Some(false); + settings.editor.show_signature_help_after_edits = Some(false); + }); + }); + }); + + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + signature_help_provider: Some(lsp::SignatureHelpOptions { + ..Default::default() + }), + ..Default::default() + }, + cx, + ) + .await; + + let language = Language::new( + LanguageConfig { + name: "Rust".into(), + brackets: BracketPairConfig { + pairs: vec![ + BracketPair { + start: "{".to_string(), + end: "}".to_string(), + close: true, + surround: true, + newline: true, + }, + BracketPair { + start: "(".to_string(), + end: ")".to_string(), + close: true, + surround: true, + newline: true, + }, + BracketPair { + start: "/*".to_string(), + end: " */".to_string(), + close: true, + surround: true, + newline: true, + }, + BracketPair { + start: "[".to_string(), + end: "]".to_string(), + close: false, + surround: false, + newline: true, + }, + BracketPair { + start: "\"".to_string(), + end: "\"".to_string(), + close: true, + surround: true, + newline: false, + }, + BracketPair { + start: "<".to_string(), + end: ">".to_string(), + close: false, + surround: true, + newline: true, + }, + ], + ..Default::default() + }, + autoclose_before: "})]".to_string(), + ..Default::default() + }, + Some(tree_sitter_rust::LANGUAGE.into()), + ); + let language = Arc::new(language); + + cx.language_registry().add(language.clone()); + cx.update_buffer(|buffer, cx| { + buffer.set_language(Some(language), cx); + }); + + // Ensure that signature_help is not called when no signature help is enabled. + cx.set_state( + &r#" + fn main() { + sampleˇ + } + "# + .unindent(), + ); + cx.update_editor(|editor, window, cx| { + editor.handle_input("(", window, cx); + }); + cx.assert_editor_state( + &" + fn main() { + sample(ˇ) + } + " + .unindent(), + ); + cx.editor(|editor, _, _| { + assert!(editor.signature_help_state.task().is_none()); + }); + + let mocked_response = lsp::SignatureHelp { + signatures: vec![lsp::SignatureInformation { + label: "fn sample(param1: u8, param2: u8)".to_string(), + documentation: None, + parameters: Some(vec![ + lsp::ParameterInformation { + label: lsp::ParameterLabel::Simple("param1: u8".to_string()), + documentation: None, + }, + lsp::ParameterInformation { + label: lsp::ParameterLabel::Simple("param2: u8".to_string()), + documentation: None, + }, + ]), + active_parameter: None, + }], + active_signature: Some(0), + active_parameter: Some(0), + }; + + // Ensure that signature_help is called when enabled afte edits + cx.update(|_, cx| { + cx.update_global::(|settings, cx| { + settings.update_user_settings(cx, |settings| { + settings.editor.auto_signature_help = Some(false); + settings.editor.show_signature_help_after_edits = Some(true); + }); + }); + }); + cx.set_state( + &r#" + fn main() { + sampleˇ + } + "# + .unindent(), + ); + cx.update_editor(|editor, window, cx| { + editor.handle_input("(", window, cx); + }); + cx.assert_editor_state( + &" + fn main() { + sample(ˇ) + } + " + .unindent(), + ); + handle_signature_help_request(&mut cx, mocked_response.clone()).await; + cx.condition(|editor, _| editor.signature_help_state.is_shown()) + .await; + cx.update_editor(|editor, _, _| { + let signature_help_state = editor.signature_help_state.popover().cloned(); + assert!(signature_help_state.is_some()); + let signature = signature_help_state.unwrap(); + assert_eq!( + signature.signatures[signature.current_signature].label, + "fn sample(param1: u8, param2: u8)" + ); + editor.signature_help_state = SignatureHelpState::default(); + }); + + // Ensure that signature_help is called when auto signature help override is enabled + cx.update(|_, cx| { + cx.update_global::(|settings, cx| { + settings.update_user_settings(cx, |settings| { + settings.editor.auto_signature_help = Some(true); + settings.editor.show_signature_help_after_edits = Some(false); + }); + }); + }); + cx.set_state( + &r#" + fn main() { + sampleˇ + } + "# + .unindent(), + ); + cx.update_editor(|editor, window, cx| { + editor.handle_input("(", window, cx); + }); + cx.assert_editor_state( + &" + fn main() { + sample(ˇ) + } + " + .unindent(), + ); + handle_signature_help_request(&mut cx, mocked_response).await; + cx.condition(|editor, _| editor.signature_help_state.is_shown()) + .await; + cx.editor(|editor, _, _| { + let signature_help_state = editor.signature_help_state.popover().cloned(); + assert!(signature_help_state.is_some()); + let signature = signature_help_state.unwrap(); + assert_eq!( + signature.signatures[signature.current_signature].label, + "fn sample(param1: u8, param2: u8)" + ); + }); +} + +#[gpui::test] +async fn test_signature_help(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + cx.update(|cx| { + cx.update_global::(|settings, cx| { + settings.update_user_settings(cx, |settings| { + settings.editor.auto_signature_help = Some(true); + }); + }); + }); + + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + signature_help_provider: Some(lsp::SignatureHelpOptions { + ..Default::default() + }), + ..Default::default() + }, + cx, + ) + .await; + + // A test that directly calls `show_signature_help` + cx.update_editor(|editor, window, cx| { + editor.show_signature_help(&ShowSignatureHelp, window, cx); + }); + + let mocked_response = lsp::SignatureHelp { + signatures: vec![lsp::SignatureInformation { + label: "fn sample(param1: u8, param2: u8)".to_string(), + documentation: None, + parameters: Some(vec![ + lsp::ParameterInformation { + label: lsp::ParameterLabel::Simple("param1: u8".to_string()), + documentation: None, + }, + lsp::ParameterInformation { + label: lsp::ParameterLabel::Simple("param2: u8".to_string()), + documentation: None, + }, + ]), + active_parameter: None, + }], + active_signature: Some(0), + active_parameter: Some(0), + }; + handle_signature_help_request(&mut cx, mocked_response).await; + + cx.condition(|editor, _| editor.signature_help_state.is_shown()) + .await; + + cx.editor(|editor, _, _| { + let signature_help_state = editor.signature_help_state.popover().cloned(); + assert!(signature_help_state.is_some()); + let signature = signature_help_state.unwrap(); + assert_eq!( + signature.signatures[signature.current_signature].label, + "fn sample(param1: u8, param2: u8)" + ); + }); + + // When exiting outside from inside the brackets, `signature_help` is closed. + cx.set_state(indoc! {" + fn main() { + sample(ˇ); + } + + fn sample(param1: u8, param2: u8) {} + "}); + + cx.update_editor(|editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_ranges([MultiBufferOffset(0)..MultiBufferOffset(0)]) + }); + }); + + let mocked_response = lsp::SignatureHelp { + signatures: Vec::new(), + active_signature: None, + active_parameter: None, + }; + handle_signature_help_request(&mut cx, mocked_response).await; + + cx.condition(|editor, _| !editor.signature_help_state.is_shown()) + .await; + + cx.editor(|editor, _, _| { + assert!(!editor.signature_help_state.is_shown()); + }); + + // When entering inside the brackets from outside, `show_signature_help` is automatically called. + cx.set_state(indoc! {" + fn main() { + sample(ˇ); + } + + fn sample(param1: u8, param2: u8) {} + "}); + + let mocked_response = lsp::SignatureHelp { + signatures: vec![lsp::SignatureInformation { + label: "fn sample(param1: u8, param2: u8)".to_string(), + documentation: None, + parameters: Some(vec![ + lsp::ParameterInformation { + label: lsp::ParameterLabel::Simple("param1: u8".to_string()), + documentation: None, + }, + lsp::ParameterInformation { + label: lsp::ParameterLabel::Simple("param2: u8".to_string()), + documentation: None, + }, + ]), + active_parameter: None, + }], + active_signature: Some(0), + active_parameter: Some(0), + }; + handle_signature_help_request(&mut cx, mocked_response.clone()).await; + cx.condition(|editor, _| editor.signature_help_state.is_shown()) + .await; + cx.editor(|editor, _, _| { + assert!(editor.signature_help_state.is_shown()); + }); + + // Restore the popover with more parameter input + cx.set_state(indoc! {" + fn main() { + sample(param1, param2ˇ); + } + + fn sample(param1: u8, param2: u8) {} + "}); + + let mocked_response = lsp::SignatureHelp { + signatures: vec![lsp::SignatureInformation { + label: "fn sample(param1: u8, param2: u8)".to_string(), + documentation: None, + parameters: Some(vec![ + lsp::ParameterInformation { + label: lsp::ParameterLabel::Simple("param1: u8".to_string()), + documentation: None, + }, + lsp::ParameterInformation { + label: lsp::ParameterLabel::Simple("param2: u8".to_string()), + documentation: None, + }, + ]), + active_parameter: None, + }], + active_signature: Some(0), + active_parameter: Some(1), + }; + handle_signature_help_request(&mut cx, mocked_response.clone()).await; + cx.condition(|editor, _| editor.signature_help_state.is_shown()) + .await; + + // When selecting a range, the popover is gone. + // Avoid using `cx.set_state` to not actually edit the document, just change its selections. + cx.update_editor(|editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_ranges(Some(Point::new(1, 25)..Point::new(1, 19))); + }) + }); + cx.assert_editor_state(indoc! {" + fn main() { + sample(param1, «ˇparam2»); + } + + fn sample(param1: u8, param2: u8) {} + "}); + cx.editor(|editor, _, _| { + assert!(!editor.signature_help_state.is_shown()); + }); + + // When unselecting again, the popover is back if within the brackets. + cx.update_editor(|editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_ranges(Some(Point::new(1, 19)..Point::new(1, 19))); + }) + }); + cx.assert_editor_state(indoc! {" + fn main() { + sample(param1, ˇparam2); + } + + fn sample(param1: u8, param2: u8) {} + "}); + handle_signature_help_request(&mut cx, mocked_response).await; + cx.condition(|editor, _| editor.signature_help_state.is_shown()) + .await; + cx.editor(|editor, _, _| { + assert!(editor.signature_help_state.is_shown()); + }); + + // Test to confirm that SignatureHelp does not appear after deselecting multiple ranges when it was hidden by pressing Escape. + cx.update_editor(|editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_ranges(Some(Point::new(0, 0)..Point::new(0, 0))); + s.select_ranges(Some(Point::new(1, 19)..Point::new(1, 19))); + }) + }); + cx.assert_editor_state(indoc! {" + fn main() { + sample(param1, ˇparam2); + } + + fn sample(param1: u8, param2: u8) {} + "}); + + let mocked_response = lsp::SignatureHelp { + signatures: vec![lsp::SignatureInformation { + label: "fn sample(param1: u8, param2: u8)".to_string(), + documentation: None, + parameters: Some(vec![ + lsp::ParameterInformation { + label: lsp::ParameterLabel::Simple("param1: u8".to_string()), + documentation: None, + }, + lsp::ParameterInformation { + label: lsp::ParameterLabel::Simple("param2: u8".to_string()), + documentation: None, + }, + ]), + active_parameter: None, + }], + active_signature: Some(0), + active_parameter: Some(1), + }; + handle_signature_help_request(&mut cx, mocked_response.clone()).await; + cx.condition(|editor, _| editor.signature_help_state.is_shown()) + .await; + cx.update_editor(|editor, _, cx| { + editor.hide_signature_help(cx, SignatureHelpHiddenBy::Escape); + }); + cx.condition(|editor, _| !editor.signature_help_state.is_shown()) + .await; + cx.update_editor(|editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_ranges(Some(Point::new(1, 25)..Point::new(1, 19))); + }) + }); + cx.assert_editor_state(indoc! {" + fn main() { + sample(param1, «ˇparam2»); + } + + fn sample(param1: u8, param2: u8) {} + "}); + cx.update_editor(|editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_ranges(Some(Point::new(1, 19)..Point::new(1, 19))); + }) + }); + cx.assert_editor_state(indoc! {" + fn main() { + sample(param1, ˇparam2); + } + + fn sample(param1: u8, param2: u8) {} + "}); + cx.condition(|editor, _| !editor.signature_help_state.is_shown()) // because hidden by escape + .await; +} + +#[gpui::test] +async fn test_signature_help_multiple_signatures(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + signature_help_provider: Some(lsp::SignatureHelpOptions { + ..Default::default() + }), + ..Default::default() + }, + cx, + ) + .await; + + cx.set_state(indoc! {" + fn main() { + overloadedˇ + } + "}); + + cx.update_editor(|editor, window, cx| { + editor.handle_input("(", window, cx); + editor.show_signature_help(&ShowSignatureHelp, window, cx); + }); + + // Mock response with 3 signatures + let mocked_response = lsp::SignatureHelp { + signatures: vec![ + lsp::SignatureInformation { + label: "fn overloaded(x: i32)".to_string(), + documentation: None, + parameters: Some(vec![lsp::ParameterInformation { + label: lsp::ParameterLabel::Simple("x: i32".to_string()), + documentation: None, + }]), + active_parameter: None, + }, + lsp::SignatureInformation { + label: "fn overloaded(x: i32, y: i32)".to_string(), + documentation: None, + parameters: Some(vec![ + lsp::ParameterInformation { + label: lsp::ParameterLabel::Simple("x: i32".to_string()), + documentation: None, + }, + lsp::ParameterInformation { + label: lsp::ParameterLabel::Simple("y: i32".to_string()), + documentation: None, + }, + ]), + active_parameter: None, + }, + lsp::SignatureInformation { + label: "fn overloaded(x: i32, y: i32, z: i32)".to_string(), + documentation: None, + parameters: Some(vec![ + lsp::ParameterInformation { + label: lsp::ParameterLabel::Simple("x: i32".to_string()), + documentation: None, + }, + lsp::ParameterInformation { + label: lsp::ParameterLabel::Simple("y: i32".to_string()), + documentation: None, + }, + lsp::ParameterInformation { + label: lsp::ParameterLabel::Simple("z: i32".to_string()), + documentation: None, + }, + ]), + active_parameter: None, + }, + ], + active_signature: Some(1), + active_parameter: Some(0), + }; + handle_signature_help_request(&mut cx, mocked_response).await; + + cx.condition(|editor, _| editor.signature_help_state.is_shown()) + .await; + + // Verify we have multiple signatures and the right one is selected + cx.editor(|editor, _, _| { + let popover = editor.signature_help_state.popover().cloned().unwrap(); + assert_eq!(popover.signatures.len(), 3); + // active_signature was 1, so that should be the current + assert_eq!(popover.current_signature, 1); + assert_eq!(popover.signatures[0].label, "fn overloaded(x: i32)"); + assert_eq!(popover.signatures[1].label, "fn overloaded(x: i32, y: i32)"); + assert_eq!( + popover.signatures[2].label, + "fn overloaded(x: i32, y: i32, z: i32)" + ); + }); + + // Test navigation functionality + cx.update_editor(|editor, window, cx| { + editor.signature_help_next(&crate::SignatureHelpNext, window, cx); + }); + + cx.editor(|editor, _, _| { + let popover = editor.signature_help_state.popover().cloned().unwrap(); + assert_eq!(popover.current_signature, 2); + }); + + // Test wrap around + cx.update_editor(|editor, window, cx| { + editor.signature_help_next(&crate::SignatureHelpNext, window, cx); + }); + + cx.editor(|editor, _, _| { + let popover = editor.signature_help_state.popover().cloned().unwrap(); + assert_eq!(popover.current_signature, 0); + }); + + // Test previous navigation + cx.update_editor(|editor, window, cx| { + editor.signature_help_prev(&crate::SignatureHelpPrevious, window, cx); + }); + + cx.editor(|editor, _, _| { + let popover = editor.signature_help_state.popover().cloned().unwrap(); + assert_eq!(popover.current_signature, 2); + }); +} + +#[gpui::test] +async fn test_completion_mode(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + completion_provider: Some(lsp::CompletionOptions { + resolve_provider: Some(true), + ..Default::default() + }), + ..Default::default() + }, + cx, + ) + .await; + + struct Run { + run_description: &'static str, + initial_state: String, + buffer_marked_text: String, + completion_label: &'static str, + completion_text: &'static str, + expected_with_insert_mode: String, + expected_with_replace_mode: String, + expected_with_replace_subsequence_mode: String, + expected_with_replace_suffix_mode: String, + } + + let runs = [ + Run { + run_description: "Start of word matches completion text", + initial_state: "before ediˇ after".into(), + buffer_marked_text: "before after".into(), + completion_label: "editor", + completion_text: "editor", + expected_with_insert_mode: "before editorˇ after".into(), + expected_with_replace_mode: "before editorˇ after".into(), + expected_with_replace_subsequence_mode: "before editorˇ after".into(), + expected_with_replace_suffix_mode: "before editorˇ after".into(), + }, + Run { + run_description: "Accept same text at the middle of the word", + initial_state: "before ediˇtor after".into(), + buffer_marked_text: "before after".into(), + completion_label: "editor", + completion_text: "editor", + expected_with_insert_mode: "before editorˇtor after".into(), + expected_with_replace_mode: "before editorˇ after".into(), + expected_with_replace_subsequence_mode: "before editorˇ after".into(), + expected_with_replace_suffix_mode: "before editorˇ after".into(), + }, + Run { + run_description: "End of word matches completion text -- cursor at end", + initial_state: "before torˇ after".into(), + buffer_marked_text: "before after".into(), + completion_label: "editor", + completion_text: "editor", + expected_with_insert_mode: "before editorˇ after".into(), + expected_with_replace_mode: "before editorˇ after".into(), + expected_with_replace_subsequence_mode: "before editorˇ after".into(), + expected_with_replace_suffix_mode: "before editorˇ after".into(), + }, + Run { + run_description: "End of word matches completion text -- cursor at start", + initial_state: "before ˇtor after".into(), + buffer_marked_text: "before <|tor> after".into(), + completion_label: "editor", + completion_text: "editor", + expected_with_insert_mode: "before editorˇtor after".into(), + expected_with_replace_mode: "before editorˇ after".into(), + expected_with_replace_subsequence_mode: "before editorˇ after".into(), + expected_with_replace_suffix_mode: "before editorˇ after".into(), + }, + Run { + run_description: "Prepend text containing whitespace", + initial_state: "pˇfield: bool".into(), + buffer_marked_text: ": bool".into(), + completion_label: "pub ", + completion_text: "pub ", + expected_with_insert_mode: "pub ˇfield: bool".into(), + expected_with_replace_mode: "pub ˇ: bool".into(), + expected_with_replace_subsequence_mode: "pub ˇfield: bool".into(), + expected_with_replace_suffix_mode: "pub ˇfield: bool".into(), + }, + Run { + run_description: "Add element to start of list", + initial_state: "[element_ˇelement_2]".into(), + buffer_marked_text: "[]".into(), + completion_label: "element_1", + completion_text: "element_1", + expected_with_insert_mode: "[element_1ˇelement_2]".into(), + expected_with_replace_mode: "[element_1ˇ]".into(), + expected_with_replace_subsequence_mode: "[element_1ˇelement_2]".into(), + expected_with_replace_suffix_mode: "[element_1ˇelement_2]".into(), + }, + Run { + run_description: "Add element to start of list -- first and second elements are equal", + initial_state: "[elˇelement]".into(), + buffer_marked_text: "[]".into(), + completion_label: "element", + completion_text: "element", + expected_with_insert_mode: "[elementˇelement]".into(), + expected_with_replace_mode: "[elementˇ]".into(), + expected_with_replace_subsequence_mode: "[elementˇelement]".into(), + expected_with_replace_suffix_mode: "[elementˇ]".into(), + }, + Run { + run_description: "Ends with matching suffix", + initial_state: "SubˇError".into(), + buffer_marked_text: "".into(), + completion_label: "SubscriptionError", + completion_text: "SubscriptionError", + expected_with_insert_mode: "SubscriptionErrorˇError".into(), + expected_with_replace_mode: "SubscriptionErrorˇ".into(), + expected_with_replace_subsequence_mode: "SubscriptionErrorˇ".into(), + expected_with_replace_suffix_mode: "SubscriptionErrorˇ".into(), + }, + Run { + run_description: "Suffix is a subsequence -- contiguous", + initial_state: "SubˇErr".into(), + buffer_marked_text: "".into(), + completion_label: "SubscriptionError", + completion_text: "SubscriptionError", + expected_with_insert_mode: "SubscriptionErrorˇErr".into(), + expected_with_replace_mode: "SubscriptionErrorˇ".into(), + expected_with_replace_subsequence_mode: "SubscriptionErrorˇ".into(), + expected_with_replace_suffix_mode: "SubscriptionErrorˇErr".into(), + }, + Run { + run_description: "Suffix is a subsequence -- non-contiguous -- replace intended", + initial_state: "Suˇscrirr".into(), + buffer_marked_text: "".into(), + completion_label: "SubscriptionError", + completion_text: "SubscriptionError", + expected_with_insert_mode: "SubscriptionErrorˇscrirr".into(), + expected_with_replace_mode: "SubscriptionErrorˇ".into(), + expected_with_replace_subsequence_mode: "SubscriptionErrorˇ".into(), + expected_with_replace_suffix_mode: "SubscriptionErrorˇscrirr".into(), + }, + Run { + run_description: "Suffix is a subsequence -- non-contiguous -- replace unintended", + initial_state: "foo(indˇix)".into(), + buffer_marked_text: "foo()".into(), + completion_label: "node_index", + completion_text: "node_index", + expected_with_insert_mode: "foo(node_indexˇix)".into(), + expected_with_replace_mode: "foo(node_indexˇ)".into(), + expected_with_replace_subsequence_mode: "foo(node_indexˇix)".into(), + expected_with_replace_suffix_mode: "foo(node_indexˇix)".into(), + }, + Run { + run_description: "Replace range ends before cursor - should extend to cursor", + initial_state: "before editˇo after".into(), + buffer_marked_text: "before <{ed}>it|o after".into(), + completion_label: "editor", + completion_text: "editor", + expected_with_insert_mode: "before editorˇo after".into(), + expected_with_replace_mode: "before editorˇo after".into(), + expected_with_replace_subsequence_mode: "before editorˇo after".into(), + expected_with_replace_suffix_mode: "before editorˇo after".into(), + }, + Run { + run_description: "Uses label for suffix matching", + initial_state: "before ediˇtor after".into(), + buffer_marked_text: "before after".into(), + completion_label: "editor", + completion_text: "editor()", + expected_with_insert_mode: "before editor()ˇtor after".into(), + expected_with_replace_mode: "before editor()ˇ after".into(), + expected_with_replace_subsequence_mode: "before editor()ˇ after".into(), + expected_with_replace_suffix_mode: "before editor()ˇ after".into(), + }, + Run { + run_description: "Case insensitive subsequence and suffix matching", + initial_state: "before EDiˇtoR after".into(), + buffer_marked_text: "before after".into(), + completion_label: "editor", + completion_text: "editor", + expected_with_insert_mode: "before editorˇtoR after".into(), + expected_with_replace_mode: "before editorˇ after".into(), + expected_with_replace_subsequence_mode: "before editorˇ after".into(), + expected_with_replace_suffix_mode: "before editorˇ after".into(), + }, + ]; + + for run in runs { + let run_variations = [ + (LspInsertMode::Insert, run.expected_with_insert_mode), + (LspInsertMode::Replace, run.expected_with_replace_mode), + ( + LspInsertMode::ReplaceSubsequence, + run.expected_with_replace_subsequence_mode, + ), + ( + LspInsertMode::ReplaceSuffix, + run.expected_with_replace_suffix_mode, + ), + ]; + + for (lsp_insert_mode, expected_text) in run_variations { + eprintln!( + "run = {:?}, mode = {lsp_insert_mode:.?}", + run.run_description, + ); + + update_test_language_settings(&mut cx, &|settings| { + settings.defaults.completions = Some(CompletionSettingsContent { + lsp_insert_mode: Some(lsp_insert_mode), + words: Some(WordsCompletionMode::Disabled), + words_min_length: Some(0), + ..Default::default() + }); + }); + + cx.set_state(&run.initial_state); + + // Set up resolve handler before showing completions, since resolve may be + // triggered when menu becomes visible (for documentation), not just on confirm. + cx.set_request_handler::( + move |_, _, _| async move { + Ok(lsp::CompletionItem { + additional_text_edits: None, + ..Default::default() + }) + }, + ); + + cx.update_editor(|editor, window, cx| { + editor.show_completions(&ShowCompletions, window, cx); + }); + + let counter = Arc::new(AtomicUsize::new(0)); + handle_completion_request_with_insert_and_replace( + &mut cx, + &run.buffer_marked_text, + vec![(run.completion_label, run.completion_text)], + counter.clone(), + ) + .await; + cx.condition(|editor, _| editor.context_menu_visible()) + .await; + assert_eq!(counter.load(atomic::Ordering::Acquire), 1); + + let apply_additional_edits = cx.update_editor(|editor, window, cx| { + editor + .confirm_completion(&ConfirmCompletion::default(), window, cx) + .unwrap() + }); + cx.assert_editor_state(&expected_text); + apply_additional_edits.await.unwrap(); + } + } +} + +#[gpui::test] +async fn test_completion_without_text_edit(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + let language_registry = cx.language_registry(); + let language = rust_lang(); + let completion_item = Arc::new(Mutex::new(None::)); + let mut fake_servers = language_registry.register_fake_lsp( + language.name(), + FakeLspAdapter { + capabilities: lsp::ServerCapabilities { + completion_provider: Some(lsp::CompletionOptions::default()), + ..lsp::ServerCapabilities::default() + }, + initializer: Some(Box::new({ + let completion_item = completion_item.clone(); + move |server| { + let completion_item = completion_item.clone(); + server.set_request_handler::(move |_, _| { + let completion_item = completion_item + .lock() + .clone() + .expect("completion item should be set before requesting completions"); + async move { + Ok(Some(lsp::CompletionResponse::List(lsp::CompletionList { + is_incomplete: true, + items: vec![completion_item], + item_defaults: None, + }))) + } + }); + } + })), + ..FakeLspAdapter::default() + }, + ); + language_registry.add(language.clone()); + cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); + let _language_server = fake_servers + .next() + .await + .expect("language server should start"); + cx.executor().run_until_parked(); + + for (initial_state, completion_text, filter_text, expected_states) in [ + ( + "test(value1, ˇvalue2)", + "value2=", + "value2", + [ + "test(value1, value2=ˇvalue2)", + "test(value1, value2=ˇ)", + "test(value1, value2=ˇ)", + "test(value1, value2=ˇvalue2)", + ], + ), + ( + "test(value1, valˇvalue2)", + "value2=", + "value2", + [ + "test(value1, value2=ˇvalue2)", + "test(value1, value2=ˇ)", + "test(value1, value2=ˇvalue2)", + "test(value1, value2=ˇvalue2)", + ], + ), + ( + "ediˇtor", + "editor", + "edi", + ["editorˇtor", "editorˇ", "editorˇ", "editorˇ"], + ), + ( + "strˇing", + "str", + "str", + ["strˇing", "strˇ", "strˇing", "strˇing"], + ), + ( + "SuˇErr", + "SubscriptionError", + "Su", + [ + "SubscriptionErrorˇErr", + "SubscriptionErrorˇ", + "SubscriptionErrorˇ", + "SubscriptionErrorˇErr", + ], + ), + ( + "test(value1, valueˇ)", + "value2=", + "value", + ["test(value1, value2=ˇ)"; 4], + ), + ( + "test(value1, ˇ)", + "value2=", + "v2", + ["test(value1, value2=ˇ)"; 4], + ), + ( + "test(value1, ˇv2)", + "value2=", + "v2", + [ + "test(value1, value2=ˇv2)", + "test(value1, value2=ˇ)", + "test(value1, value2=ˇ)", + "test(value1, value2=ˇv2)", + ], + ), + ( + "test(\"😀\", 𐐀naïˇve)", + "𐐀naïve", + "𐐀naï", + [ + "test(\"😀\", 𐐀naïveˇve)", + "test(\"😀\", 𐐀naïveˇ)", + "test(\"😀\", 𐐀naïveˇ)", + "test(\"😀\", 𐐀naïveˇ)", + ], + ), + ( + "test(\n\tˇvalue2\n)", + "value2=", + "value2", + [ + "test(\n\tvalue2=ˇvalue2\n)", + "test(\n\tvalue2=ˇ\n)", + "test(\n\tvalue2=ˇ\n)", + "test(\n\tvalue2=ˇvalue2\n)", + ], + ), + ( + "ˇvalue2", + "value2=", + "value2", + ["value2=ˇvalue2", "value2=ˇ", "value2=ˇ", "value2=ˇvalue2"], + ), + ("valueˇ", "value2=", "value", ["value2=ˇ"; 4]), + ("ˇ", "value2=", "v2", ["value2=ˇ"; 4]), + ] { + for (lsp_insert_mode, expected_state) in [ + LspInsertMode::Insert, + LspInsertMode::Replace, + LspInsertMode::ReplaceSubsequence, + LspInsertMode::ReplaceSuffix, + ] + .into_iter() + .zip(expected_states) + { + update_test_language_settings(&mut cx, &|settings| { + settings.defaults.completions = Some(CompletionSettingsContent { + lsp_insert_mode: Some(lsp_insert_mode), + words: Some(WordsCompletionMode::Disabled), + ..CompletionSettingsContent::default() + }); + }); + + for (insert_text, detail, description, use_filter_text) in [ + (None, None, None, false), + (Some(completion_text), None, None, false), + (None, Some("string"), None, false), + (Some(completion_text), Some("string"), None, false), + (None, None, Some("string"), false), + (Some(completion_text), None, Some("string"), false), + (Some(completion_text), None, None, true), + (Some(completion_text), Some(filter_text), None, true), + ] { + eprintln!( + "{initial_state:?}, {lsp_insert_mode:?}, {insert_text:?}, {detail:?}, {description:?}, {use_filter_text:?}" + ); + cx.set_state(initial_state); + *completion_item.lock() = Some(lsp::CompletionItem { + label: completion_text.to_string(), + filter_text: use_filter_text.then(|| filter_text.to_string()), + insert_text: insert_text.map(str::to_string), + detail: detail.map(str::to_string), + label_details: description.map(|description| lsp::CompletionItemLabelDetails { + detail: None, + description: Some(description.to_string()), + }), + ..lsp::CompletionItem::default() + }); + cx.update_editor(|editor, window, cx| { + editor.show_completions(&ShowCompletions, window, cx); + }); + cx.condition(|editor, _| editor.context_menu_visible()) + .await; + cx.update_editor(|editor, window, cx| { + editor.confirm_completion(&ConfirmCompletion::default(), window, cx) + }) + .expect("completion should be accepted") + .await + .expect("completion should succeed"); + cx.assert_editor_state(expected_state); + } + } + } +} + +#[gpui::test] +async fn test_completion_without_text_edit_after_typing(cx: &mut TestAppContext) { + init_test(cx, |settings| { + settings.defaults.completions = Some(CompletionSettingsContent { + lsp_insert_mode: Some(LspInsertMode::Replace), + words: Some(WordsCompletionMode::Disabled), + ..CompletionSettingsContent::default() + }); + }); + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + completion_provider: Some(lsp::CompletionOptions { + resolve_provider: Some(true), + ..lsp::CompletionOptions::default() + }), + ..lsp::ServerCapabilities::default() + }, + cx, + ) + .await; + + let requests = Arc::new(AtomicUsize::new(0)); + cx.set_request_handler::({ + let requests = requests.clone(); + move |_, _, _| { + requests.fetch_add(1, atomic::Ordering::Release); + async move { + Ok(Some(lsp::CompletionResponse::Array(vec![ + lsp::CompletionItem { + label: "value2=".to_string(), + insert_text: Some("value2=".to_string()), + ..lsp::CompletionItem::default() + }, + ]))) + } + } + }); + let mut resolved = cx.set_request_handler::( + |_, mut item, _| async move { + item.documentation = Some(lsp::Documentation::String("resolved".to_string())); + Ok(item) + }, + ); + cx.set_state("test(value1, ˇvalue2)"); + cx.update_editor(|editor, window, cx| { + editor.show_completions(&ShowCompletions, window, cx); + }); + cx.condition(|editor, _| editor.context_menu_visible()) + .await; + resolved.next().await.expect("completion should resolve"); + cx.run_until_parked(); + + cx.simulate_input("v"); + cx.run_until_parked(); + cx.simulate_input("a"); + cx.run_until_parked(); + assert_eq!(requests.load(atomic::Ordering::Acquire), 1); + cx.assert_editor_state("test(value1, vaˇvalue2)"); + cx.update_editor(|editor, window, cx| { + editor.confirm_completion_insert(&ConfirmCompletionInsert, window, cx) + }) + .expect("completion should be accepted") + .await + .expect("completion should succeed"); + cx.assert_editor_state("test(value1, value2=ˇvalue2)"); +} + +#[gpui::test] +async fn test_completion_with_explicit_replace_range(cx: &mut TestAppContext) { + init_test(cx, |settings| { + settings.defaults.completions = Some(CompletionSettingsContent { + lsp_insert_mode: Some(LspInsertMode::Insert), + words: Some(WordsCompletionMode::Disabled), + ..CompletionSettingsContent::default() + }); + }); + let mut cx = EditorTestContext::new(cx).await; + let language_registry = cx.language_registry(); + let language = rust_lang(); + let completion_list = Arc::new(Mutex::new(None::)); + let mut fake_servers = language_registry.register_fake_lsp( + language.name(), + FakeLspAdapter { + capabilities: lsp::ServerCapabilities { + completion_provider: Some(lsp::CompletionOptions::default()), + ..lsp::ServerCapabilities::default() + }, + initializer: Some(Box::new({ + let completion_list = completion_list.clone(); + move |server| { + let completion_list = completion_list.clone(); + server.set_request_handler::(move |_, _| { + let completion_list = completion_list + .lock() + .clone() + .expect("completion list should be set before requesting completions"); + async move { Ok(Some(lsp::CompletionResponse::List(completion_list))) } + }); + } + })), + ..FakeLspAdapter::default() + }, + ); + language_registry.add(language.clone()); + cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); + let _language_server = fake_servers + .next() + .await + .expect("language server should start"); + cx.executor().run_until_parked(); + + for (lsp_insert_mode, expected_pair_state) in [ + (LspInsertMode::ReplaceSuffix, "test(value1, value2=ˇvalue2)"), + (LspInsertMode::Insert, "test(value1, value2=ˇvalue2)"), + (LspInsertMode::Replace, "test(value1, value2=ˇ)"), + (LspInsertMode::ReplaceSubsequence, "test(value1, value2=ˇ)"), + ] { + update_test_language_settings(&mut cx, &|settings| { + settings.defaults.completions = Some(CompletionSettingsContent { + lsp_insert_mode: Some(lsp_insert_mode), + words: Some(WordsCompletionMode::Disabled), + ..CompletionSettingsContent::default() + }); + }); + for (use_default_range, use_insert_range) in + [(false, true), (true, true), (false, false), (true, false)] + { + for filter_text in [Some("value2"), None] { + eprintln!( + "{lsp_insert_mode:?}, {use_default_range:?}, {use_insert_range:?}, {filter_text:?}" + ); + cx.set_state("test(value1, ˇvalue2)"); + let insert = lsp::Range::new(lsp::Position::new(0, 13), lsp::Position::new(0, 13)); + let replace = lsp::Range::new(lsp::Position::new(0, 13), lsp::Position::new(0, 19)); + let text_edit = if use_insert_range { + lsp::CompletionTextEdit::InsertAndReplace(lsp::InsertReplaceEdit { + insert, + replace, + new_text: "value2=".to_string(), + }) + } else { + lsp::CompletionTextEdit::Edit(lsp::TextEdit { + range: replace, + new_text: "value2=".to_string(), + }) + }; + let edit_range = if use_insert_range { + lsp::CompletionListItemDefaultsEditRange::InsertAndReplace { insert, replace } + } else { + lsp::CompletionListItemDefaultsEditRange::Range(replace) + }; + *completion_list.lock() = Some(lsp::CompletionList { + items: vec![lsp::CompletionItem { + label: "value2=".to_string(), + filter_text: filter_text.map(str::to_string), + text_edit: (!use_default_range).then_some(text_edit), + ..lsp::CompletionItem::default() + }], + item_defaults: use_default_range.then(|| lsp::CompletionListItemDefaults { + edit_range: Some(edit_range), + ..lsp::CompletionListItemDefaults::default() + }), + ..lsp::CompletionList::default() + }); + cx.update_editor(|editor, window, cx| { + editor.show_completions(&ShowCompletions, window, cx); + }); + cx.condition(|editor, _| editor.context_menu_visible()) + .await; + cx.update_editor(|editor, window, cx| { + editor.confirm_completion(&ConfirmCompletion::default(), window, cx) + }) + .expect("completion should be accepted") + .await + .expect("completion should succeed"); + cx.assert_editor_state(if use_insert_range { + expected_pair_state + } else { + "test(value1, value2=ˇ)" + }); + } + } + } +} + +#[gpui::test] +async fn test_completion_with_mode_specified_by_action(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + completion_provider: Some(lsp::CompletionOptions { + resolve_provider: Some(true), + ..Default::default() + }), + ..Default::default() + }, + cx, + ) + .await; + + let initial_state = "SubˇError"; + let buffer_marked_text = ""; + let completion_text = "SubscriptionError"; + let expected_with_insert_mode = "SubscriptionErrorˇError"; + let expected_with_replace_mode = "SubscriptionErrorˇ"; + + update_test_language_settings(&mut cx, &|settings| { + settings.defaults.completions = Some(CompletionSettingsContent { + words: Some(WordsCompletionMode::Disabled), + words_min_length: Some(0), + // set the opposite here to ensure that the action is overriding the default behavior + lsp_insert_mode: Some(LspInsertMode::Insert), + ..Default::default() + }); + }); + + cx.set_state(initial_state); + cx.update_editor(|editor, window, cx| { + editor.show_completions(&ShowCompletions, window, cx); + }); + + let counter = Arc::new(AtomicUsize::new(0)); + handle_completion_request_with_insert_and_replace( + &mut cx, + buffer_marked_text, + vec![(completion_text, completion_text)], + counter.clone(), + ) + .await; + cx.condition(|editor, _| editor.context_menu_visible()) + .await; + assert_eq!(counter.load(atomic::Ordering::Acquire), 1); + + let apply_additional_edits = cx.update_editor(|editor, window, cx| { + editor + .confirm_completion_replace(&ConfirmCompletionReplace, window, cx) + .unwrap() + }); + cx.assert_editor_state(expected_with_replace_mode); + handle_resolve_completion_request(&mut cx, None).await; + apply_additional_edits.await.unwrap(); + + update_test_language_settings(&mut cx, &|settings| { + settings.defaults.completions = Some(CompletionSettingsContent { + words: Some(WordsCompletionMode::Disabled), + words_min_length: Some(0), + // set the opposite here to ensure that the action is overriding the default behavior + lsp_insert_mode: Some(LspInsertMode::Replace), + ..Default::default() + }); + }); + + cx.set_state(initial_state); + cx.update_editor(|editor, window, cx| { + editor.show_completions(&ShowCompletions, window, cx); + }); + handle_completion_request_with_insert_and_replace( + &mut cx, + buffer_marked_text, + vec![(completion_text, completion_text)], + counter.clone(), + ) + .await; + cx.condition(|editor, _| editor.context_menu_visible()) + .await; + assert_eq!(counter.load(atomic::Ordering::Acquire), 2); + + let apply_additional_edits = cx.update_editor(|editor, window, cx| { + editor + .confirm_completion_insert(&ConfirmCompletionInsert, window, cx) + .unwrap() + }); + cx.assert_editor_state(expected_with_insert_mode); + handle_resolve_completion_request(&mut cx, None).await; + apply_additional_edits.await.unwrap(); +} + +#[gpui::test] +async fn test_completion_replacing_surrounding_text_with_multicursors(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + completion_provider: Some(lsp::CompletionOptions { + resolve_provider: Some(true), + ..Default::default() + }), + ..Default::default() + }, + cx, + ) + .await; + + // scenario: surrounding text matches completion text + let completion_text = "to_offset"; + let initial_state = indoc! {" + 1. buf.to_offˇsuffix + 2. buf.to_offˇsuf + 3. buf.to_offˇfix + 4. buf.to_offˇ + 5. into_offˇensive + 6. ˇsuffix + 7. let ˇ // + 8. aaˇzz + 9. buf.to_off«zzzzzˇ»suffix + 10. buf.«ˇzzzzz»suffix + 11. to_off«ˇzzzzz» + + buf.to_offˇsuffix // newest cursor + "}; + let completion_marked_buffer = indoc! {" + 1. buf.to_offsuffix + 2. buf.to_offsuf + 3. buf.to_offfix + 4. buf.to_off + 5. into_offensive + 6. suffix + 7. let // + 8. aazz + 9. buf.to_offzzzzzsuffix + 10. buf.zzzzzsuffix + 11. to_offzzzzz + + buf. // newest cursor + "}; + let expected = indoc! {" + 1. buf.to_offsetˇ + 2. buf.to_offsetˇsuf + 3. buf.to_offsetˇfix + 4. buf.to_offsetˇ + 5. into_offsetˇensive + 6. to_offsetˇsuffix + 7. let to_offsetˇ // + 8. aato_offsetˇzz + 9. buf.to_offsetˇ + 10. buf.to_offsetˇsuffix + 11. to_offsetˇ + + buf.to_offsetˇ // newest cursor + "}; + cx.set_state(initial_state); + cx.update_editor(|editor, window, cx| { + editor.show_completions(&ShowCompletions, window, cx); + }); + handle_completion_request_with_insert_and_replace( + &mut cx, + completion_marked_buffer, + vec![(completion_text, completion_text)], + Arc::new(AtomicUsize::new(0)), + ) + .await; + cx.condition(|editor, _| editor.context_menu_visible()) + .await; + let apply_additional_edits = cx.update_editor(|editor, window, cx| { + editor + .confirm_completion_replace(&ConfirmCompletionReplace, window, cx) + .unwrap() + }); + cx.assert_editor_state(expected); + handle_resolve_completion_request(&mut cx, None).await; + apply_additional_edits.await.unwrap(); + + // scenario: surrounding text matches surroundings of newest cursor, inserting at the end + let completion_text = "foo_and_bar"; + let initial_state = indoc! {" + 1. ooanbˇ + 2. zooanbˇ + 3. ooanbˇz + 4. zooanbˇz + 5. ooanˇ + 6. oanbˇ + + ooanbˇ + "}; + let completion_marked_buffer = indoc! {" + 1. ooanb + 2. zooanb + 3. ooanbz + 4. zooanbz + 5. ooan + 6. oanb + + + "}; + let expected = indoc! {" + 1. foo_and_barˇ + 2. zfoo_and_barˇ + 3. foo_and_barˇz + 4. zfoo_and_barˇz + 5. ooanfoo_and_barˇ + 6. oanbfoo_and_barˇ + + foo_and_barˇ + "}; + cx.set_state(initial_state); + cx.update_editor(|editor, window, cx| { + editor.show_completions(&ShowCompletions, window, cx); + }); + handle_completion_request_with_insert_and_replace( + &mut cx, + completion_marked_buffer, + vec![(completion_text, completion_text)], + Arc::new(AtomicUsize::new(0)), + ) + .await; + cx.condition(|editor, _| editor.context_menu_visible()) + .await; + let apply_additional_edits = cx.update_editor(|editor, window, cx| { + editor + .confirm_completion_replace(&ConfirmCompletionReplace, window, cx) + .unwrap() + }); + cx.assert_editor_state(expected); + handle_resolve_completion_request(&mut cx, None).await; + apply_additional_edits.await.unwrap(); + + // scenario: surrounding text matches surroundings of newest cursor, inserted at the middle + // (expects the same as if it was inserted at the end) + let completion_text = "foo_and_bar"; + let initial_state = indoc! {" + 1. ooˇanb + 2. zooˇanb + 3. ooˇanbz + 4. zooˇanbz + + ooˇanb + "}; + let completion_marked_buffer = indoc! {" + 1. ooanb + 2. zooanb + 3. ooanbz + 4. zooanbz + + + "}; + let expected = indoc! {" + 1. foo_and_barˇ + 2. zfoo_and_barˇ + 3. foo_and_barˇz + 4. zfoo_and_barˇz + + foo_and_barˇ + "}; + cx.set_state(initial_state); + cx.update_editor(|editor, window, cx| { + editor.show_completions(&ShowCompletions, window, cx); + }); + handle_completion_request_with_insert_and_replace( + &mut cx, + completion_marked_buffer, + vec![(completion_text, completion_text)], + Arc::new(AtomicUsize::new(0)), + ) + .await; + cx.condition(|editor, _| editor.context_menu_visible()) + .await; + let apply_additional_edits = cx.update_editor(|editor, window, cx| { + editor + .confirm_completion_replace(&ConfirmCompletionReplace, window, cx) + .unwrap() + }); + cx.assert_editor_state(expected); + handle_resolve_completion_request(&mut cx, None).await; + apply_additional_edits.await.unwrap(); +} + +// This used to crash +#[gpui::test] +async fn test_completion_in_multibuffer_with_replace_range(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let buffer_text = indoc! {" + fn main() { + 10.satu; + + // + // separate1 + // separate2 + // separate3 + // + + 10.satu20; + } + "}; + let multibuffer_text_with_selections = indoc! {" + fn main() { + 10.satuˇ; + + // + + 10.satuˇ20; + } + "}; + let expected_multibuffer = indoc! {" + fn main() { + 10.saturating_sub()ˇ; + + // + + 10.saturating_sub()ˇ; + } + "}; + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/a"), + json!({ + "main.rs": buffer_text, + }), + ) + .await; + + let project = Project::test(fs, [path!("/a").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(rust_lang()); + let mut fake_servers = language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + capabilities: lsp::ServerCapabilities { + completion_provider: Some(lsp::CompletionOptions { + resolve_provider: None, + ..lsp::CompletionOptions::default() + }), + ..lsp::ServerCapabilities::default() + }, + ..FakeLspAdapter::default() + }, + ); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(*window, cx); + let buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/a/main.rs"), cx) + }) + .await + .unwrap(); + + let multi_buffer = cx.new(|cx| { + let mut multi_buffer = MultiBuffer::new(Capability::ReadWrite); + multi_buffer.set_excerpts_for_path( + PathKey::sorted(0), + buffer.clone(), + [ + Point::zero()..Point::new(2, 0), + Point::new(7, 0)..buffer.read(cx).max_point(), + ], + 0, + cx, + ); + multi_buffer + }); + + let editor = workspace.update_in(cx, |_, window, cx| { + cx.new(|cx| { + Editor::new( + EditorMode::Full { + scale_ui_elements_with_buffer_font_size: false, + show_active_line_background: false, + sizing_behavior: SizingBehavior::Default, + }, + multi_buffer.clone(), + Some(project.clone()), + window, + cx, + ) + }) + }); + + let pane = workspace.update_in(cx, |workspace, _, _| workspace.active_pane().clone()); + pane.update_in(cx, |pane, window, cx| { + pane.add_item(Box::new(editor.clone()), true, true, None, window, cx); + }); + + let fake_server = fake_servers.next().await.unwrap(); + cx.run_until_parked(); + + editor.update_in(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_ranges([ + Point::new(1, 11)..Point::new(1, 11), + Point::new(5, 11)..Point::new(5, 11), + ]) + }); + + assert_text_with_selections(editor, multibuffer_text_with_selections, cx); + }); + + editor.update_in(cx, |editor, window, cx| { + editor.show_completions(&ShowCompletions, window, cx); + }); + + fake_server + .set_request_handler::(move |_, _| async move { + let completion_item = lsp::CompletionItem { + label: "saturating_sub()".into(), + text_edit: Some(lsp::CompletionTextEdit::InsertAndReplace( + lsp::InsertReplaceEdit { + new_text: "saturating_sub()".to_owned(), + insert: lsp::Range::new( + lsp::Position::new(9, 7), + lsp::Position::new(9, 11), + ), + replace: lsp::Range::new( + lsp::Position::new(9, 7), + lsp::Position::new(9, 13), + ), + }, + )), + ..lsp::CompletionItem::default() + }; + + Ok(Some(lsp::CompletionResponse::Array(vec![completion_item]))) + }) + .next() + .await + .unwrap(); + + cx.condition(&editor, |editor, _| editor.context_menu_visible()) + .await; + + editor + .update_in(cx, |editor, window, cx| { + editor + .confirm_completion_replace(&ConfirmCompletionReplace, window, cx) + .unwrap() + }) + .await + .unwrap(); + + editor.update(cx, |editor, cx| { + assert_text_with_selections(editor, expected_multibuffer, cx); + }) +} + +#[gpui::test] +async fn test_completion_in_multibuffer_with_newest_selection_in_other_buffer( + cx: &mut TestAppContext, +) { + init_test(cx, |_| {}); + + let main_text = indoc! {" + fn main() { + 10.satu + } + "}; + let other_text = indoc! {" + const VALUE: u32 = 0; + "}; + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/a"), + json!({ + "main.rs": main_text, + "other.rs": other_text, + }), + ) + .await; + + let project = Project::test(fs, [path!("/a").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(rust_lang()); + let mut fake_servers = language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + capabilities: lsp::ServerCapabilities { + completion_provider: Some(lsp::CompletionOptions { + resolve_provider: None, + ..lsp::CompletionOptions::default() + }), + ..lsp::ServerCapabilities::default() + }, + ..FakeLspAdapter::default() + }, + ); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(*window, cx); + let main_buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/a/main.rs"), cx) + }) + .await + .unwrap(); + let other_buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/a/other.rs"), cx) + }) + .await + .unwrap(); + + let multi_buffer = cx.new(|cx| { + let mut multi_buffer = MultiBuffer::new(Capability::ReadWrite); + multi_buffer.set_excerpts_for_path( + PathKey::sorted(0), + main_buffer.clone(), + [Point::zero()..main_buffer.read(cx).max_point()], + 0, + cx, + ); + multi_buffer.set_excerpts_for_path( + PathKey::sorted(1), + other_buffer.clone(), + [Point::zero()..other_buffer.read(cx).max_point()], + 0, + cx, + ); + multi_buffer + }); + + let editor = workspace.update_in(cx, |_, window, cx| { + cx.new(|cx| { + Editor::new( + EditorMode::Full { + scale_ui_elements_with_buffer_font_size: false, + show_active_line_background: false, + sizing_behavior: SizingBehavior::Default, + }, + multi_buffer.clone(), + Some(project.clone()), + window, + cx, + ) + }) + }); + + let pane = workspace.update_in(cx, |workspace, _, _| workspace.active_pane().clone()); + pane.update_in(cx, |pane, window, cx| { + pane.add_item(Box::new(editor.clone()), true, true, None, window, cx); + }); + + let fake_server = fake_servers.next().await.unwrap(); + cx.run_until_parked(); + + // Place the (only, and therefore newest) selection in `main.rs`, right after `satu`. + let main_cursor = editor.update(cx, |editor, cx| { + let multibuffer_snapshot = editor.buffer().read(cx).snapshot(cx); + let main_snapshot = main_buffer.read(cx).snapshot(); + let anchor = main_snapshot.anchor_before(Point::new(1, 11)); + multibuffer_snapshot + .buffer_anchor_range_to_anchor_range(anchor..anchor) + .expect("main.rs cursor position should map into the multibuffer") + }); + editor.update_in(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_anchor_ranges([main_cursor.clone()]); + }); + }); + + editor.update_in(cx, |editor, window, cx| { + editor.show_completions(&ShowCompletions, window, cx); + }); + + fake_server + .set_request_handler::(move |_, _| async move { + let completion_item = lsp::CompletionItem { + label: "saturating_sub()".into(), + text_edit: Some(lsp::CompletionTextEdit::InsertAndReplace( + lsp::InsertReplaceEdit { + new_text: "saturating_sub()".to_owned(), + insert: lsp::Range::new( + lsp::Position::new(1, 7), + lsp::Position::new(1, 11), + ), + replace: lsp::Range::new( + lsp::Position::new(1, 7), + lsp::Position::new(1, 11), + ), + }, + )), + ..lsp::CompletionItem::default() + }; + + Ok(Some(lsp::CompletionResponse::Array(vec![completion_item]))) + }) + .next() + .await + .unwrap(); + + cx.condition(&editor, |editor, _| editor.context_menu_visible()) + .await; + + // Move the newest selection into `other.rs` while keeping the completions menu + // open. + let other_cursor = editor.update(cx, |editor, cx| { + let multibuffer_snapshot = editor.buffer().read(cx).snapshot(cx); + let other_snapshot = other_buffer.read(cx).snapshot(); + let anchor = other_snapshot.anchor_before(Point::new(0, 6)); + multibuffer_snapshot + .buffer_anchor_range_to_anchor_range(anchor..anchor) + .expect("other.rs cursor position should map into the multibuffer") + }); + editor.update_in(cx, |editor, window, cx| { + editor.change_selections( + SelectionEffects::no_scroll().completions(false), + window, + cx, + |s| s.select_anchor_ranges([other_cursor.clone()]), + ); + assert!( + editor.context_menu_visible(), + "the completions menu should still be open after moving the cursor" + ); + }); + + // This used to panic with `invalid anchor - buffer id does not match`. + editor + .update_in(cx, |editor, window, cx| { + editor + .confirm_completion_replace(&ConfirmCompletionReplace, window, cx) + .unwrap() + }) + .await + .unwrap(); + + // The completion targets `main.rs`, so it should still be applied there. + main_buffer.read_with(cx, |buffer, _| { + assert_eq!( + buffer.text(), + indoc! {" + fn main() { + 10.saturating_sub() + } + "} + ); + }); +} + +#[gpui::test] +async fn test_completion_filter_text_need_not_be_label_substring(cx: &mut TestAppContext) { + init_test(cx, |language_settings| { + language_settings.defaults.completions = Some(CompletionSettingsContent { + words: Some(WordsCompletionMode::Disabled), + ..Default::default() + }); + }); + + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + completion_provider: Some(lsp::CompletionOptions::default()), + ..Default::default() + }, + cx, + ) + .await; + + cx.lsp + .set_request_handler::(move |_, _| async move { + Ok(Some(lsp::CompletionResponse::Array(vec![ + lsp::CompletionItem { + label: "rendered value".to_string(), + filter_text: Some("typed_prefix".to_string()), + ..Default::default() + }, + lsp::CompletionItem { + label: "typed_prefix fallback".to_string(), + ..Default::default() + }, + ]))) + }); + + cx.set_state("fn main() { typed_prefixˇ }"); + cx.update_editor(|editor, window, cx| { + editor.show_completions(&ShowCompletions, window, cx); + }); + + cx.run_until_parked(); + cx.condition(|editor, _| editor.context_menu_visible()) + .await; + + cx.update_editor(|editor, _, _| { + let context_menu = editor.context_menu.borrow(); + let Some(CodeContextMenu::Completions(menu)) = context_menu.as_ref() else { + panic!("expected completion menu to be open"); + }; + + let completions = menu.completions.borrow(); + let entries = menu.entries.borrow(); + let completion = entries + .iter() + .filter_map(|entry| entry.as_match()) + .map(|string_match| &completions[string_match.candidate_id]) + .find(|completion| completion.label.text() == "rendered value") + .expect("completion should be matched using its non-substring filterText"); + + assert_eq!(completion.label.filter_text(), "rendered value"); + assert_eq!(completion.filter_text(), "typed_prefix"); + }); +} + +#[gpui::test] +async fn test_completion(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + completion_provider: Some(lsp::CompletionOptions { + trigger_characters: Some(vec![".".to_string(), ":".to_string()]), + resolve_provider: Some(true), + ..Default::default() + }), + signature_help_provider: Some(lsp::SignatureHelpOptions::default()), + ..Default::default() + }, + cx, + ) + .await; + let counter = Arc::new(AtomicUsize::new(0)); + + cx.set_state(indoc! {" + oneˇ + two + three + "}); + cx.simulate_keystroke("."); + handle_completion_request( + indoc! {" + one.|<> + two + three + "}, + vec!["first_completion", "second_completion"], + true, + counter.clone(), + &mut cx, + ) + .await; + cx.condition(|editor, _| editor.context_menu_visible()) + .await; + assert_eq!(counter.load(atomic::Ordering::Acquire), 1); + + let _handler = handle_signature_help_request( + &mut cx, + lsp::SignatureHelp { + signatures: vec![lsp::SignatureInformation { + label: "test signature".to_string(), + documentation: None, + parameters: Some(vec![lsp::ParameterInformation { + label: lsp::ParameterLabel::Simple("foo: u8".to_string()), + documentation: None, + }]), + active_parameter: None, + }], + active_signature: None, + active_parameter: None, + }, + ); + cx.update_editor(|editor, window, cx| { + assert!( + !editor.signature_help_state.is_shown(), + "No signature help was called for" + ); + editor.show_signature_help(&ShowSignatureHelp, window, cx); + }); + cx.run_until_parked(); + cx.update_editor(|editor, _, _| { + assert!( + !editor.signature_help_state.is_shown(), + "No signature help should be shown when completions menu is open" + ); + }); + + let apply_additional_edits = cx.update_editor(|editor, window, cx| { + editor.context_menu_next(&Default::default(), window, cx); + editor + .confirm_completion(&ConfirmCompletion::default(), window, cx) + .unwrap() + }); + cx.assert_editor_state(indoc! {" + one.second_completionˇ + two + three + "}); + + handle_resolve_completion_request( + &mut cx, + Some(vec![ + ( + //This overlaps with the primary completion edit which is + //misbehavior from the LSP spec, test that we filter it out + indoc! {" + one.second_ˇcompletion + two + threeˇ + "}, + "overlapping additional edit", + ), + ( + indoc! {" + one.second_completion + two + threeˇ + "}, + "\nadditional edit", + ), + ]), + ) + .await; + apply_additional_edits.await.unwrap(); + cx.assert_editor_state(indoc! {" + one.second_completionˇ + two + three + additional edit + "}); + + cx.set_state(indoc! {" + one.second_completion + twoˇ + threeˇ + additional edit + "}); + cx.simulate_keystroke(" "); + assert!(cx.editor(|e, _, _| e.context_menu.borrow_mut().is_none())); + cx.simulate_keystroke("s"); + assert!(cx.editor(|e, _, _| e.context_menu.borrow_mut().is_none())); + + cx.assert_editor_state(indoc! {" + one.second_completion + two sˇ + three sˇ + additional edit + "}); + handle_completion_request( + indoc! {" + one.second_completion + two s + three + additional edit + "}, + vec!["fourth_completion", "fifth_completion", "sixth_completion"], + true, + counter.clone(), + &mut cx, + ) + .await; + cx.condition(|editor, _| editor.context_menu_visible()) + .await; + assert_eq!(counter.load(atomic::Ordering::Acquire), 2); + + cx.simulate_keystroke("i"); + + handle_completion_request( + indoc! {" + one.second_completion + two si + three + additional edit + "}, + vec!["fourth_completion", "fifth_completion", "sixth_completion"], + true, + counter.clone(), + &mut cx, + ) + .await; + cx.condition(|editor, _| editor.context_menu_visible()) + .await; + assert_eq!(counter.load(atomic::Ordering::Acquire), 3); + + let apply_additional_edits = cx.update_editor(|editor, window, cx| { + editor + .confirm_completion(&ConfirmCompletion::default(), window, cx) + .unwrap() + }); + cx.assert_editor_state(indoc! {" + one.second_completion + two sixth_completionˇ + three sixth_completionˇ + additional edit + "}); + + apply_additional_edits.await.unwrap(); + + update_test_language_settings(&mut cx, &|settings| { + settings.defaults.show_completions_on_input = Some(false); + }); + cx.set_state("editorˇ"); + cx.simulate_keystroke("."); + assert!(cx.editor(|e, _, _| e.context_menu.borrow_mut().is_none())); + cx.simulate_keystrokes("c l o"); + cx.assert_editor_state("editor.cloˇ"); + assert!(cx.editor(|e, _, _| e.context_menu.borrow_mut().is_none())); + cx.update_editor(|editor, window, cx| { + editor.show_completions(&ShowCompletions, window, cx); + }); + handle_completion_request( + "editor.", + vec!["close", "clobber"], + true, + counter.clone(), + &mut cx, + ) + .await; + cx.condition(|editor, _| editor.context_menu_visible()) + .await; + assert_eq!(counter.load(atomic::Ordering::Acquire), 4); + + let apply_additional_edits = cx.update_editor(|editor, window, cx| { + editor + .confirm_completion(&ConfirmCompletion::default(), window, cx) + .unwrap() + }); + cx.assert_editor_state("editor.clobberˇ"); + handle_resolve_completion_request(&mut cx, None).await; + apply_additional_edits.await.unwrap(); +} + +#[gpui::test] +async fn test_completion_can_run_commands(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/a"), + json!({ + "main.rs": "", + }), + ) + .await; + + let project = Project::test(fs, [path!("/a").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(rust_lang()); + let command_calls = Arc::new(AtomicUsize::new(0)); + let registered_command = "_the/command"; + + let closure_command_calls = command_calls.clone(); + let mut fake_servers = language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + capabilities: lsp::ServerCapabilities { + completion_provider: Some(lsp::CompletionOptions { + trigger_characters: Some(vec![".".to_string(), ":".to_string()]), + ..lsp::CompletionOptions::default() + }), + execute_command_provider: Some(lsp::ExecuteCommandOptions { + commands: vec![registered_command.to_owned()], + ..lsp::ExecuteCommandOptions::default() + }), + ..lsp::ServerCapabilities::default() + }, + initializer: Some(Box::new(move |fake_server| { + fake_server.set_request_handler::( + move |params, _| async move { + Ok(Some(lsp::CompletionResponse::Array(vec![ + lsp::CompletionItem { + label: "registered_command".to_owned(), + text_edit: gen_text_edit(¶ms, ""), + command: Some(lsp::Command { + title: registered_command.to_owned(), + command: "_the/command".to_owned(), + arguments: Some(vec![serde_json::Value::Bool(true)]), + }), + ..lsp::CompletionItem::default() + }, + lsp::CompletionItem { + label: "unregistered_command".to_owned(), + text_edit: gen_text_edit(¶ms, ""), + command: Some(lsp::Command { + title: "????????????".to_owned(), + command: "????????????".to_owned(), + arguments: Some(vec![serde_json::Value::Null]), + }), + ..lsp::CompletionItem::default() + }, + ]))) + }, + ); + fake_server.set_request_handler::({ + let command_calls = closure_command_calls.clone(); + move |params, _| { + assert_eq!(params.command, registered_command); + let command_calls = command_calls.clone(); + async move { + command_calls.fetch_add(1, atomic::Ordering::Release); + Ok(Some(json!(null))) + } + } + }); + })), + ..FakeLspAdapter::default() + }, + ); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(*window, cx); + let editor = workspace + .update_in(cx, |workspace, window, cx| { + workspace.open_abs_path( + PathBuf::from(path!("/a/main.rs")), + OpenOptions::default(), + window, + cx, + ) + }) + .await + .unwrap() + .downcast::() + .unwrap(); + let _fake_server = fake_servers.next().await.unwrap(); + cx.run_until_parked(); + + editor.update_in(cx, |editor, window, cx| { + cx.focus_self(window); + editor.move_to_end(&MoveToEnd, window, cx); + editor.handle_input(".", window, cx); + }); + cx.run_until_parked(); + editor.update(cx, |editor, _| { + assert!(editor.context_menu_visible()); + if let Some(CodeContextMenu::Completions(menu)) = editor.context_menu.borrow_mut().as_ref() + { + let completion_labels = menu + .completions + .borrow() + .iter() + .map(|c| c.label.text.clone()) + .collect::>(); + assert_eq!( + completion_labels, + &["registered_command", "unregistered_command",], + ); + } else { + panic!("expected completion menu to be open"); + } + }); + + editor + .update_in(cx, |editor, window, cx| { + editor + .confirm_completion(&ConfirmCompletion::default(), window, cx) + .unwrap() + }) + .await + .unwrap(); + cx.run_until_parked(); + assert_eq!( + command_calls.load(atomic::Ordering::Acquire), + 1, + "For completion with a registered command, Zed should send a command execution request", + ); + + editor.update_in(cx, |editor, window, cx| { + cx.focus_self(window); + editor.handle_input(".", window, cx); + }); + cx.run_until_parked(); + editor.update(cx, |editor, _| { + assert!(editor.context_menu_visible()); + if let Some(CodeContextMenu::Completions(menu)) = editor.context_menu.borrow_mut().as_ref() + { + let completion_labels = menu + .completions + .borrow() + .iter() + .map(|c| c.label.text.clone()) + .collect::>(); + assert_eq!( + completion_labels, + &["registered_command", "unregistered_command",], + ); + } else { + panic!("expected completion menu to be open"); + } + }); + editor + .update_in(cx, |editor, window, cx| { + editor.context_menu_next(&Default::default(), window, cx); + editor + .confirm_completion(&ConfirmCompletion::default(), window, cx) + .unwrap() + }) + .await + .unwrap(); + cx.run_until_parked(); + assert_eq!( + command_calls.load(atomic::Ordering::Acquire), + 1, + "For completion with an unregistered command, Zed should not send a command execution request", + ); +} + +#[gpui::test] +async fn test_completion_reuse(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + completion_provider: Some(lsp::CompletionOptions { + trigger_characters: Some(vec![".".to_string()]), + ..Default::default() + }), + ..Default::default() + }, + cx, + ) + .await; + + let counter = Arc::new(AtomicUsize::new(0)); + cx.set_state("objˇ"); + cx.simulate_keystroke("."); + + // Initial completion request returns complete results + let is_incomplete = false; + handle_completion_request( + "obj.|<>", + vec!["a", "ab", "abc"], + is_incomplete, + counter.clone(), + &mut cx, + ) + .await; + cx.run_until_parked(); + assert_eq!(counter.load(atomic::Ordering::Acquire), 1); + cx.assert_editor_state("obj.ˇ"); + check_displayed_completions(vec!["a", "ab", "abc"], &mut cx); + + // Type "a" - filters existing completions + cx.simulate_keystroke("a"); + cx.run_until_parked(); + assert_eq!(counter.load(atomic::Ordering::Acquire), 1); + cx.assert_editor_state("obj.aˇ"); + check_displayed_completions(vec!["a", "ab", "abc"], &mut cx); + + // Type "b" - filters existing completions + cx.simulate_keystroke("b"); + cx.run_until_parked(); + assert_eq!(counter.load(atomic::Ordering::Acquire), 1); + cx.assert_editor_state("obj.abˇ"); + check_displayed_completions(vec!["ab", "abc"], &mut cx); + + // Type "c" - filters existing completions + cx.simulate_keystroke("c"); + cx.run_until_parked(); + assert_eq!(counter.load(atomic::Ordering::Acquire), 1); + cx.assert_editor_state("obj.abcˇ"); + check_displayed_completions(vec!["abc"], &mut cx); + + // Backspace to delete "c" - filters existing completions + cx.update_editor(|editor, window, cx| { + editor.backspace(&Backspace, window, cx); + }); + cx.run_until_parked(); + assert_eq!(counter.load(atomic::Ordering::Acquire), 1); + cx.assert_editor_state("obj.abˇ"); + check_displayed_completions(vec!["ab", "abc"], &mut cx); + + // Moving cursor to the left dismisses menu. + cx.update_editor(|editor, window, cx| { + editor.move_left(&MoveLeft, window, cx); + }); + cx.run_until_parked(); + assert_eq!(counter.load(atomic::Ordering::Acquire), 1); + cx.assert_editor_state("obj.aˇb"); + cx.update_editor(|editor, _, _| { + assert_eq!(editor.context_menu_visible(), false); + }); + + // Type "b" - new request + cx.simulate_keystroke("b"); + let is_incomplete = false; + handle_completion_request( + "obj.a", + vec!["ab", "abc"], + is_incomplete, + counter.clone(), + &mut cx, + ) + .await; + cx.run_until_parked(); + assert_eq!(counter.load(atomic::Ordering::Acquire), 2); + cx.assert_editor_state("obj.abˇb"); + check_displayed_completions(vec!["ab", "abc"], &mut cx); + + // Backspace to delete "b" - since query was "ab" and is now "a", new request is made. + cx.update_editor(|editor, window, cx| { + editor.backspace(&Backspace, window, cx); + }); + let is_incomplete = false; + handle_completion_request( + "obj.b", + vec!["a", "ab", "abc"], + is_incomplete, + counter.clone(), + &mut cx, + ) + .await; + cx.run_until_parked(); + assert_eq!(counter.load(atomic::Ordering::Acquire), 3); + cx.assert_editor_state("obj.aˇb"); + check_displayed_completions(vec!["a", "ab", "abc"], &mut cx); + + // Backspace to delete "a" - dismisses menu. + cx.update_editor(|editor, window, cx| { + editor.backspace(&Backspace, window, cx); + }); + cx.run_until_parked(); + assert_eq!(counter.load(atomic::Ordering::Acquire), 3); + cx.assert_editor_state("obj.ˇb"); + cx.update_editor(|editor, _, _| { + assert_eq!(editor.context_menu_visible(), false); + }); +} + +#[gpui::test] +async fn test_word_completion(cx: &mut TestAppContext) { + let lsp_fetch_timeout_ms = 10; + init_test(cx, |language_settings| { + language_settings.defaults.completions = Some(CompletionSettingsContent { + words_min_length: Some(0), + lsp_fetch_timeout_ms: Some(10), + lsp_insert_mode: Some(LspInsertMode::Insert), + ..Default::default() + }); + }); + + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + completion_provider: Some(lsp::CompletionOptions { + trigger_characters: Some(vec![".".to_string(), ":".to_string()]), + ..lsp::CompletionOptions::default() + }), + signature_help_provider: Some(lsp::SignatureHelpOptions::default()), + ..lsp::ServerCapabilities::default() + }, + cx, + ) + .await; + + let throttle_completions = Arc::new(AtomicBool::new(false)); + + let lsp_throttle_completions = throttle_completions.clone(); + let _completion_requests_handler = + cx.lsp + .server + .on_request::(move |_, cx| { + let lsp_throttle_completions = lsp_throttle_completions.clone(); + let cx = cx.clone(); + async move { + if lsp_throttle_completions.load(atomic::Ordering::Acquire) { + cx.background_executor() + .timer(Duration::from_millis(lsp_fetch_timeout_ms * 10)) + .await; + } + Ok(Some(lsp::CompletionResponse::Array(vec![ + lsp::CompletionItem { + label: "first".into(), + ..lsp::CompletionItem::default() + }, + lsp::CompletionItem { + label: "last".into(), + ..lsp::CompletionItem::default() + }, + ]))) + } + }); + + cx.set_state(indoc! {" + oneˇ + two + three + "}); + cx.simulate_keystroke("."); + cx.executor().run_until_parked(); + cx.condition(|editor, _| editor.context_menu_visible()) + .await; + cx.update_editor(|editor, window, cx| { + if let Some(CodeContextMenu::Completions(menu)) = editor.context_menu.borrow_mut().as_ref() + { + assert_eq!( + completion_menu_entries(menu), + &["first", "last"], + "When LSP server is fast to reply, no fallback word completions are used" + ); + } else { + panic!("expected completion menu to be open"); + } + editor.cancel(&Cancel, window, cx); + }); + cx.executor().run_until_parked(); + cx.condition(|editor, _| !editor.context_menu_visible()) + .await; + + throttle_completions.store(true, atomic::Ordering::Release); + cx.simulate_keystroke("."); + cx.executor() + .advance_clock(Duration::from_millis(lsp_fetch_timeout_ms * 2)); + cx.executor().run_until_parked(); + cx.condition(|editor, _| editor.context_menu_visible()) + .await; + cx.update_editor(|editor, _, _| { + if let Some(CodeContextMenu::Completions(menu)) = editor.context_menu.borrow_mut().as_ref() + { + assert_eq!(completion_menu_entries(menu), &["one", "three", "two"], + "When LSP server is slow, document words can be shown instead, if configured accordingly"); + } else { + panic!("expected completion menu to be open"); + } + }); +} + +#[gpui::test] +async fn test_word_completions_do_not_duplicate_lsp_ones(cx: &mut TestAppContext) { + init_test(cx, |language_settings| { + language_settings.defaults.completions = Some(CompletionSettingsContent { + words: Some(WordsCompletionMode::Enabled), + words_min_length: Some(0), + lsp_insert_mode: Some(LspInsertMode::Insert), + ..Default::default() + }); + }); + + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + completion_provider: Some(lsp::CompletionOptions { + trigger_characters: Some(vec![".".to_string(), ":".to_string()]), + ..lsp::CompletionOptions::default() + }), + signature_help_provider: Some(lsp::SignatureHelpOptions::default()), + ..lsp::ServerCapabilities::default() + }, + cx, + ) + .await; + + let _completion_requests_handler = + cx.lsp + .server + .on_request::(move |_, _| async move { + Ok(Some(lsp::CompletionResponse::Array(vec![ + lsp::CompletionItem { + label: "first".into(), + ..lsp::CompletionItem::default() + }, + lsp::CompletionItem { + label: "last".into(), + ..lsp::CompletionItem::default() + }, + ]))) + }); + + cx.set_state(indoc! {"ˇ + first + last + second + "}); + cx.simulate_keystroke("."); + cx.executor().run_until_parked(); + cx.condition(|editor, _| editor.context_menu_visible()) + .await; + cx.update_editor(|editor, _, _| { + if let Some(CodeContextMenu::Completions(menu)) = editor.context_menu.borrow_mut().as_ref() + { + assert_eq!( + completion_menu_entries(menu), + &["first", "last", "second"], + "Word completions that has the same edit as the any of the LSP ones, should not be proposed" + ); + } else { + panic!("expected completion menu to be open"); + } + }); +} + +#[gpui::test] +async fn test_word_completions_continue_on_typing(cx: &mut TestAppContext) { + init_test(cx, |language_settings| { + language_settings.defaults.completions = Some(CompletionSettingsContent { + words: Some(WordsCompletionMode::Disabled), + words_min_length: Some(0), + lsp_insert_mode: Some(LspInsertMode::Insert), + ..Default::default() + }); + }); + + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + completion_provider: Some(lsp::CompletionOptions { + trigger_characters: Some(vec![".".to_string(), ":".to_string()]), + ..lsp::CompletionOptions::default() + }), + signature_help_provider: Some(lsp::SignatureHelpOptions::default()), + ..lsp::ServerCapabilities::default() + }, + cx, + ) + .await; + + let _completion_requests_handler = + cx.lsp + .server + .on_request::(move |_, _| async move { + panic!("LSP completions should not be queried when dealing with word completions") + }); + + cx.set_state(indoc! {"ˇ + first + last + second + "}); + cx.update_editor(|editor, window, cx| { + editor.show_word_completions(&ShowWordCompletions, window, cx); + }); + cx.executor().run_until_parked(); + cx.condition(|editor, _| editor.context_menu_visible()) + .await; + cx.update_editor(|editor, _, _| { + if let Some(CodeContextMenu::Completions(menu)) = editor.context_menu.borrow_mut().as_ref() + { + assert_eq!( + completion_menu_entries(menu), + &["first", "last", "second"], + "`ShowWordCompletions` action should show word completions" + ); + } else { + panic!("expected completion menu to be open"); + } + }); + + cx.simulate_keystroke("l"); + cx.executor().run_until_parked(); + cx.condition(|editor, _| editor.context_menu_visible()) + .await; + cx.update_editor(|editor, _, _| { + if let Some(CodeContextMenu::Completions(menu)) = editor.context_menu.borrow_mut().as_ref() + { + assert_eq!( + completion_menu_entries(menu), + &["last"], + "After showing word completions, further editing should filter them and not query the LSP" + ); + } else { + panic!("expected completion menu to be open"); + } + }); +} + +#[gpui::test] +async fn test_completions_use_selection_head(cx: &mut TestAppContext) { + init_test(cx, |language_settings| { + language_settings.defaults.completions = Some(CompletionSettingsContent { + words: Some(WordsCompletionMode::Disabled), + words_min_length: Some(0), + lsp_insert_mode: Some(LspInsertMode::Insert), + ..Default::default() + }); + }); + + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + completion_provider: Some(lsp::CompletionOptions::default()), + ..lsp::ServerCapabilities::default() + }, + cx, + ) + .await; + + let _completion_requests_handler = + cx.lsp + .server + .on_request::(move |_, _| async move { + panic!("LSP completions should not be queried when dealing with word completions") + }); + + cx.set_state(indoc! {"«applˇ» + applepie + banana + cherry + "}); + cx.update_editor(|editor, window, cx| { + editor.show_word_completions(&ShowWordCompletions, window, cx); + }); + cx.executor().run_until_parked(); + cx.condition(|editor, _| editor.context_menu_visible()) + .await; + cx.update_editor(|editor, _, _| { + if let Some(CodeContextMenu::Completions(menu)) = editor.context_menu.borrow_mut().as_ref() + { + assert_eq!( + completion_menu_entries(menu), + &["applepie"], + "Completion query should use the selection head (`appl`), filtering to words with that prefix" + ); + } else { + panic!("expected completion menu to be open"); + } + }); +} + +#[gpui::test] +async fn test_word_completions_usually_skip_digits(cx: &mut TestAppContext) { + init_test(cx, |language_settings| { + language_settings.defaults.completions = Some(CompletionSettingsContent { + words_min_length: Some(0), + lsp: Some(false), + lsp_insert_mode: Some(LspInsertMode::Insert), + ..Default::default() + }); + }); + + let mut cx = EditorLspTestContext::new_rust(lsp::ServerCapabilities::default(), cx).await; + + cx.set_state(indoc! {"ˇ + 0_usize + let + 33 + 4.5f32 + "}); + cx.update_editor(|editor, window, cx| { + editor.show_completions(&ShowCompletions, window, cx); + }); + cx.executor().run_until_parked(); + cx.condition(|editor, _| editor.context_menu_visible()) + .await; + cx.update_editor(|editor, window, cx| { + if let Some(CodeContextMenu::Completions(menu)) = editor.context_menu.borrow_mut().as_ref() + { + assert_eq!( + completion_menu_entries(menu), + &["let"], + "With no digits in the completion query, no digits should be in the word completions" + ); + } else { + panic!("expected completion menu to be open"); + } + editor.cancel(&Cancel, window, cx); + }); + + cx.set_state(indoc! {"3ˇ + 0_usize + let + 3 + 33.35f32 + "}); + cx.update_editor(|editor, window, cx| { + editor.show_completions(&ShowCompletions, window, cx); + }); + cx.executor().run_until_parked(); + cx.condition(|editor, _| editor.context_menu_visible()) + .await; + cx.update_editor(|editor, _, _| { + if let Some(CodeContextMenu::Completions(menu)) = editor.context_menu.borrow_mut().as_ref() + { + assert_eq!(completion_menu_entries(menu), &["33", "35f32"], "The digit is in the completion query, \ + return matching words with digits (`33`, `35f32`) but exclude query duplicates (`3`)"); + } else { + panic!("expected completion menu to be open"); + } + }); +} + +#[gpui::test] +async fn test_word_completions_do_not_show_before_threshold(cx: &mut TestAppContext) { + init_test(cx, |language_settings| { + language_settings.defaults.completions = Some(CompletionSettingsContent { + words: Some(WordsCompletionMode::Enabled), + words_min_length: Some(3), + lsp_insert_mode: Some(LspInsertMode::Insert), + ..Default::default() + }); + }); + + let mut cx = EditorLspTestContext::new_rust(lsp::ServerCapabilities::default(), cx).await; + cx.set_state(indoc! {"ˇ + wow + wowen + wowser + "}); + cx.simulate_keystroke("w"); + cx.executor().run_until_parked(); + cx.update_editor(|editor, _, _| { + if editor.context_menu.borrow_mut().is_some() { + panic!( + "expected completion menu to be hidden, as words completion threshold is not met" + ); + } + }); + + cx.update_editor(|editor, window, cx| { + editor.show_word_completions(&ShowWordCompletions, window, cx); + }); + cx.executor().run_until_parked(); + cx.update_editor(|editor, window, cx| { + if let Some(CodeContextMenu::Completions(menu)) = editor.context_menu.borrow_mut().as_ref() + { + assert_eq!(completion_menu_entries(menu), &["wowser", "wowen", "wow"], "Even though the threshold is not met, invoking word completions with an action should provide the completions"); + } else { + panic!("expected completion menu to be open after the word completions are called with an action"); + } + + editor.cancel(&Cancel, window, cx); + }); + cx.update_editor(|editor, _, _| { + if editor.context_menu.borrow_mut().is_some() { + panic!("expected completion menu to be hidden after canceling"); + } + }); + + cx.simulate_keystroke("o"); + cx.executor().run_until_parked(); + cx.update_editor(|editor, _, _| { + if editor.context_menu.borrow_mut().is_some() { + panic!( + "expected completion menu to be hidden, as words completion threshold is not met still" + ); + } + }); + + cx.simulate_keystroke("w"); + cx.executor().run_until_parked(); + cx.update_editor(|editor, _, _| { + if let Some(CodeContextMenu::Completions(menu)) = editor.context_menu.borrow_mut().as_ref() + { + assert_eq!(completion_menu_entries(menu), &["wowen", "wowser"], "After word completion threshold is met, matching words should be shown, excluding the already typed word"); + } else { + panic!("expected completion menu to be open after the word completions threshold is met"); + } + }); +} + +#[gpui::test] +async fn test_word_completions_disabled(cx: &mut TestAppContext) { + init_test(cx, |language_settings| { + language_settings.defaults.completions = Some(CompletionSettingsContent { + words: Some(WordsCompletionMode::Enabled), + words_min_length: Some(0), + lsp_insert_mode: Some(LspInsertMode::Insert), + ..Default::default() + }); + }); + + let mut cx = EditorLspTestContext::new_rust(lsp::ServerCapabilities::default(), cx).await; + cx.update_editor(|editor, _, _| { + editor.disable_word_completions(); + }); + cx.set_state(indoc! {"ˇ + wow + wowen + wowser + "}); + cx.simulate_keystroke("w"); + cx.executor().run_until_parked(); + cx.update_editor(|editor, _, _| { + if editor.context_menu.borrow_mut().is_some() { + panic!( + "expected completion menu to be hidden, as words completion are disabled for this editor" + ); + } + }); + + cx.update_editor(|editor, window, cx| { + editor.show_word_completions(&ShowWordCompletions, window, cx); + }); + cx.executor().run_until_parked(); + cx.update_editor(|editor, _, _| { + if editor.context_menu.borrow_mut().is_some() { + panic!( + "expected completion menu to be hidden even if called for explicitly, as words completion are disabled for this editor" + ); + } + }); +} + +#[gpui::test] +async fn test_word_completions_disabled_with_no_provider(cx: &mut TestAppContext) { + init_test(cx, |language_settings| { + language_settings.defaults.completions = Some(CompletionSettingsContent { + words: Some(WordsCompletionMode::Disabled), + words_min_length: Some(0), + lsp_insert_mode: Some(LspInsertMode::Insert), + ..Default::default() + }); + }); + + let mut cx = EditorLspTestContext::new_rust(lsp::ServerCapabilities::default(), cx).await; + cx.update_editor(|editor, _, _| { + editor.set_completion_provider(None); + }); + cx.set_state(indoc! {"ˇ + wow + wowen + wowser + "}); + cx.simulate_keystroke("w"); + cx.executor().run_until_parked(); + cx.update_editor(|editor, _, _| { + if editor.context_menu.borrow_mut().is_some() { + panic!("expected completion menu to be hidden, as disabled in settings"); + } + }); +} + +fn gen_text_edit(params: &CompletionParams, text: &str) -> Option { + let position = || lsp::Position { + line: params.text_document_position.position.line, + character: params.text_document_position.position.character, + }; + Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit { + range: lsp::Range { + start: position(), + end: position(), + }, + new_text: text.to_string(), + })) +} + +#[gpui::test] +async fn test_multiline_completion(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/a"), + json!({ + "main.ts": "a", + }), + ) + .await; + + let project = Project::test(fs, [path!("/a").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + let typescript_language = Arc::new(Language::new( + LanguageConfig { + name: "TypeScript".into(), + matcher: (LanguageMatcher { + path_suffixes: vec!["ts".to_string()], + ..LanguageMatcher::default() + }) + .into(), + line_comments: vec!["// ".into()], + ..LanguageConfig::default() + }, + Some(tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into()), + )); + language_registry.add(typescript_language.clone()); + let mut fake_servers = language_registry.register_fake_lsp( + "TypeScript", + FakeLspAdapter { + capabilities: lsp::ServerCapabilities { + completion_provider: Some(lsp::CompletionOptions { + trigger_characters: Some(vec![".".to_string(), ":".to_string()]), + ..lsp::CompletionOptions::default() + }), + signature_help_provider: Some(lsp::SignatureHelpOptions::default()), + ..lsp::ServerCapabilities::default() + }, + // Emulate vtsls label generation + label_for_completion: Some(Box::new(|item, _| { + let text = if let Some(description) = item + .label_details + .as_ref() + .and_then(|label_details| label_details.description.as_ref()) + { + format!("{} {}", item.label, description) + } else if let Some(detail) = &item.detail { + format!("{} {}", item.label, detail) + } else { + item.label.clone() + }; + Some(language::CodeLabel::plain(text, None)) + })), + ..FakeLspAdapter::default() + }, + ); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(*window, cx); + let worktree_id = workspace.update_in(cx, |workspace, _window, cx| { + workspace.project().update(cx, |project, cx| { + project.worktrees(cx).next().unwrap().read(cx).id() + }) + }); + + let _buffer = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/a/main.ts"), cx) + }) + .await + .unwrap(); + let editor = workspace + .update_in(cx, |workspace, window, cx| { + workspace.open_path((worktree_id, rel_path("main.ts")), None, true, window, cx) + }) + .await + .unwrap() + .downcast::() + .unwrap(); + let fake_server = fake_servers.next().await.unwrap(); + cx.run_until_parked(); + + let multiline_label = "StickyHeaderExcerpt {\n excerpt,\n next_excerpt_controls_present,\n next_buffer_row,\n }: StickyHeaderExcerpt<'_>,"; + let multiline_label_2 = "a\nb\nc\n"; + let multiline_detail = "[]struct {\n\tSignerId\tstruct {\n\t\tIssuer\t\t\tstring\t`json:\"issuer\"`\n\t\tSubjectSerialNumber\"`\n}}"; + let multiline_description = "d\ne\nf\n"; + let multiline_detail_2 = "g\nh\ni\n"; + + let mut completion_handle = fake_server.set_request_handler::( + move |params, _| async move { + Ok(Some(lsp::CompletionResponse::Array(vec![ + lsp::CompletionItem { + label: multiline_label.to_string(), + text_edit: gen_text_edit(¶ms, "new_text_1"), + ..lsp::CompletionItem::default() + }, + lsp::CompletionItem { + label: "single line label 1".to_string(), + detail: Some(multiline_detail.to_string()), + text_edit: gen_text_edit(¶ms, "new_text_2"), + ..lsp::CompletionItem::default() + }, + lsp::CompletionItem { + label: "single line label 2".to_string(), + label_details: Some(lsp::CompletionItemLabelDetails { + description: Some(multiline_description.to_string()), + detail: None, + }), + text_edit: gen_text_edit(¶ms, "new_text_2"), + ..lsp::CompletionItem::default() + }, + lsp::CompletionItem { + label: multiline_label_2.to_string(), + detail: Some(multiline_detail_2.to_string()), + text_edit: gen_text_edit(¶ms, "new_text_3"), + ..lsp::CompletionItem::default() + }, + lsp::CompletionItem { + label: "Label with many spaces and \t but without newlines".to_string(), + detail: Some( + "Details with many spaces and \t but without newlines".to_string(), + ), + text_edit: gen_text_edit(¶ms, "new_text_4"), + ..lsp::CompletionItem::default() + }, + ]))) + }, + ); + + editor.update_in(cx, |editor, window, cx| { + cx.focus_self(window); + editor.move_to_end(&MoveToEnd, window, cx); + editor.handle_input(".", window, cx); + }); + cx.run_until_parked(); + completion_handle.next().await.unwrap(); + + editor.update(cx, |editor, _| { + assert!(editor.context_menu_visible()); + if let Some(CodeContextMenu::Completions(menu)) = editor.context_menu.borrow_mut().as_ref() + { + let completion_labels = menu + .completions + .borrow() + .iter() + .map(|c| c.label.text.clone()) + .collect::>(); + assert_eq!( + completion_labels, + &[ + "StickyHeaderExcerpt { excerpt, next_excerpt_controls_present, next_buffer_row, }: StickyHeaderExcerpt<'_>,", + "single line label 1 []struct { SignerId struct { Issuer string `json:\"issuer\"` SubjectSerialNumber\"` }}", + "single line label 2 d e f ", + "a b c g h i ", + "Label with many spaces and \t but without newlines Details with many spaces and \t but without newlines", + ], + "Completion items should have their labels without newlines, also replacing excessive whitespaces. Completion items without newlines should not be altered.", + ); + + for completion in menu + .completions + .borrow() + .iter() { + assert_eq!( + completion.label.filter_range, + 0..completion.label.text.len(), + "Adjusted completion items should still keep their filter ranges for the entire label. Item: {completion:?}" + ); + } + } else { + panic!("expected completion menu to be open"); + } + }); +} + +#[gpui::test] +async fn test_completion_page_up_down_keys(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + completion_provider: Some(lsp::CompletionOptions { + trigger_characters: Some(vec![".".to_string()]), + ..Default::default() + }), + ..Default::default() + }, + cx, + ) + .await; + cx.lsp + .set_request_handler::(move |_, _| async move { + Ok(Some(lsp::CompletionResponse::Array(vec![ + lsp::CompletionItem { + label: "first".into(), + ..Default::default() + }, + lsp::CompletionItem { + label: "last".into(), + ..Default::default() + }, + ]))) + }); + cx.set_state("variableˇ"); + cx.simulate_keystroke("."); + cx.executor().run_until_parked(); + + cx.update_editor(|editor, _, _| { + if let Some(CodeContextMenu::Completions(menu)) = editor.context_menu.borrow_mut().as_ref() + { + assert_eq!(completion_menu_entries(menu), &["first", "last"]); + } else { + panic!("expected completion menu to be open"); + } + }); + + cx.update_editor(|editor, window, cx| { + editor.move_page_down(&MovePageDown::default(), window, cx); + if let Some(CodeContextMenu::Completions(menu)) = editor.context_menu.borrow_mut().as_ref() + { + assert!( + menu.selected_item == 1, + "expected PageDown to select the last item from the context menu" + ); + } else { + panic!("expected completion menu to stay open after PageDown"); + } + }); + + cx.update_editor(|editor, window, cx| { + editor.move_page_up(&MovePageUp::default(), window, cx); + if let Some(CodeContextMenu::Completions(menu)) = editor.context_menu.borrow_mut().as_ref() + { + assert!( + menu.selected_item == 0, + "expected PageUp to select the first item from the context menu" + ); + } else { + panic!("expected completion menu to stay open after PageUp"); + } + }); +} + +#[gpui::test] +async fn test_as_is_completions(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + completion_provider: Some(lsp::CompletionOptions { + ..Default::default() + }), + ..Default::default() + }, + cx, + ) + .await; + cx.lsp + .set_request_handler::(move |_, _| async move { + Ok(Some(lsp::CompletionResponse::Array(vec![ + lsp::CompletionItem { + label: "unsafe".into(), + text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit { + range: lsp::Range { + start: lsp::Position { + line: 1, + character: 2, + }, + end: lsp::Position { + line: 1, + character: 3, + }, + }, + new_text: "unsafe".to_string(), + })), + insert_text_mode: Some(lsp::InsertTextMode::AS_IS), + ..Default::default() + }, + ]))) + }); + cx.set_state("fn a() {}\n nˇ"); + cx.executor().run_until_parked(); + cx.update_editor(|editor, window, cx| { + editor.trigger_completion_on_input("n", true, window, cx) + }); + cx.executor().run_until_parked(); + + cx.update_editor(|editor, window, cx| { + editor.confirm_completion(&Default::default(), window, cx) + }); + cx.executor().run_until_parked(); + cx.assert_editor_state("fn a() {}\n unsafeˇ"); +} + +#[gpui::test] +async fn test_panic_during_c_completions(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let language = + Arc::try_unwrap(languages::language("c", tree_sitter_c::LANGUAGE.into())).unwrap(); + let mut cx = EditorLspTestContext::new( + language, + lsp::ServerCapabilities { + completion_provider: Some(lsp::CompletionOptions { + ..lsp::CompletionOptions::default() + }), + ..lsp::ServerCapabilities::default() + }, + cx, + ) + .await; + + cx.set_state( + "#ifndef BAR_H +#define BAR_H + +#include + +int fn_branch(bool do_branch1, bool do_branch2); + +#endif // BAR_H +ˇ", + ); + cx.executor().run_until_parked(); + cx.update_editor(|editor, window, cx| { + editor.handle_input("#", window, cx); + }); + cx.executor().run_until_parked(); + cx.update_editor(|editor, window, cx| { + editor.handle_input("i", window, cx); + }); + cx.executor().run_until_parked(); + cx.update_editor(|editor, window, cx| { + editor.handle_input("n", window, cx); + }); + cx.executor().run_until_parked(); + cx.assert_editor_state( + "#ifndef BAR_H +#define BAR_H + +#include + +int fn_branch(bool do_branch1, bool do_branch2); + +#endif // BAR_H +#inˇ", + ); + + cx.lsp + .set_request_handler::(move |_, _| async move { + Ok(Some(lsp::CompletionResponse::List(lsp::CompletionList { + is_incomplete: false, + item_defaults: None, + items: vec![lsp::CompletionItem { + kind: Some(lsp::CompletionItemKind::SNIPPET), + label_details: Some(lsp::CompletionItemLabelDetails { + detail: Some("header".to_string()), + description: None, + }), + label: " include".to_string(), + text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit { + range: lsp::Range { + start: lsp::Position { + line: 8, + character: 1, + }, + end: lsp::Position { + line: 8, + character: 1, + }, + }, + new_text: "include \"$0\"".to_string(), + })), + sort_text: Some("40b67681include".to_string()), + insert_text_format: Some(lsp::InsertTextFormat::SNIPPET), + filter_text: Some("include".to_string()), + insert_text: Some("include \"$0\"".to_string()), + ..lsp::CompletionItem::default() + }], + }))) + }); + cx.update_editor(|editor, window, cx| { + editor.show_completions(&ShowCompletions, window, cx); + }); + cx.executor().run_until_parked(); + cx.update_editor(|editor, window, cx| { + editor.confirm_completion(&ConfirmCompletion::default(), window, cx) + }); + cx.executor().run_until_parked(); + cx.assert_editor_state( + "#ifndef BAR_H +#define BAR_H + +#include + +int fn_branch(bool do_branch1, bool do_branch2); + +#endif // BAR_H +#include \"ˇ\"", + ); + + cx.lsp + .set_request_handler::(move |_, _| async move { + Ok(Some(lsp::CompletionResponse::List(lsp::CompletionList { + is_incomplete: true, + item_defaults: None, + items: vec![lsp::CompletionItem { + kind: Some(lsp::CompletionItemKind::FILE), + label: "AGL/".to_string(), + text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit { + range: lsp::Range { + start: lsp::Position { + line: 8, + character: 10, + }, + end: lsp::Position { + line: 8, + character: 11, + }, + }, + new_text: "AGL/".to_string(), + })), + sort_text: Some("40b67681AGL/".to_string()), + insert_text_format: Some(lsp::InsertTextFormat::PLAIN_TEXT), + filter_text: Some("AGL/".to_string()), + insert_text: Some("AGL/".to_string()), + ..lsp::CompletionItem::default() + }], + }))) + }); + cx.update_editor(|editor, window, cx| { + editor.show_completions(&ShowCompletions, window, cx); + }); + cx.executor().run_until_parked(); + cx.update_editor(|editor, window, cx| { + editor.confirm_completion(&ConfirmCompletion::default(), window, cx) + }); + cx.executor().run_until_parked(); + cx.assert_editor_state( + r##"#ifndef BAR_H +#define BAR_H + +#include + +int fn_branch(bool do_branch1, bool do_branch2); + +#endif // BAR_H +#include "AGL/ˇ"##, + ); + + cx.update_editor(|editor, window, cx| { + editor.handle_input("\"", window, cx); + }); + cx.executor().run_until_parked(); + cx.assert_editor_state( + r##"#ifndef BAR_H +#define BAR_H + +#include + +int fn_branch(bool do_branch1, bool do_branch2); + +#endif // BAR_H +#include "AGL/"ˇ"##, + ); +} + +#[gpui::test] +async fn test_no_duplicated_completion_requests(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + completion_provider: Some(lsp::CompletionOptions { + trigger_characters: Some(vec![".".to_string()]), + resolve_provider: Some(false), + ..lsp::CompletionOptions::default() + }), + ..lsp::ServerCapabilities::default() + }, + cx, + ) + .await; + + cx.set_state("fn main() { let a = 2ˇ; }"); + cx.simulate_keystroke("."); + let completion_item = lsp::CompletionItem { + label: "Some".into(), + kind: Some(lsp::CompletionItemKind::SNIPPET), + detail: Some("Wrap the expression in an `Option::Some`".to_string()), + documentation: Some(lsp::Documentation::MarkupContent(lsp::MarkupContent { + kind: lsp::MarkupKind::Markdown, + value: "```rust\nSome(2)\n```".to_string(), + })), + deprecated: Some(false), + sort_text: Some("Some".to_string()), + filter_text: Some("Some".to_string()), + insert_text_format: Some(lsp::InsertTextFormat::SNIPPET), + text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit { + range: lsp::Range { + start: lsp::Position { + line: 0, + character: 22, + }, + end: lsp::Position { + line: 0, + character: 22, + }, + }, + new_text: "Some(2)".to_string(), + })), + additional_text_edits: Some(vec![lsp::TextEdit { + range: lsp::Range { + start: lsp::Position { + line: 0, + character: 20, + }, + end: lsp::Position { + line: 0, + character: 22, + }, + }, + new_text: "".to_string(), + }]), + ..Default::default() + }; + + let closure_completion_item = completion_item.clone(); + let counter = Arc::new(AtomicUsize::new(0)); + let counter_clone = counter.clone(); + let mut request = cx.set_request_handler::(move |_, _, _| { + let task_completion_item = closure_completion_item.clone(); + counter_clone.fetch_add(1, atomic::Ordering::Release); + async move { + Ok(Some(lsp::CompletionResponse::List(lsp::CompletionList { + is_incomplete: true, + item_defaults: None, + items: vec![task_completion_item], + }))) + } + }); + + cx.executor().run_until_parked(); + cx.condition(|editor, _| editor.context_menu_visible()) + .await; + cx.assert_editor_state("fn main() { let a = 2.ˇ; }"); + assert!(request.next().await.is_some()); + assert_eq!(counter.load(atomic::Ordering::Acquire), 1); + + cx.simulate_keystrokes("S o m"); + cx.condition(|editor, _| editor.context_menu_visible()) + .await; + cx.assert_editor_state("fn main() { let a = 2.Somˇ; }"); + assert!(request.next().await.is_some()); + assert!(request.next().await.is_some()); + assert!(request.next().await.is_some()); + request.close(); + assert!(request.next().await.is_none()); + assert_eq!( + counter.load(atomic::Ordering::Acquire), + 4, + "With the completions menu open, only one LSP request should happen per input" + ); +} + +#[gpui::test] +async fn test_toggle_comment(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + let language = Arc::new(Language::new( + LanguageConfig { + line_comments: vec!["// ".into(), "//! ".into(), "/// ".into()], + ..Default::default() + }, + Some(tree_sitter_rust::LANGUAGE.into()), + )); + cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); + + // If multiple selections intersect a line, the line is only toggled once. + cx.set_state(indoc! {" + fn a() { + «//b(); + ˇ»// «c(); + //ˇ» d(); + } + "}); + + cx.update_editor(|e, window, cx| e.toggle_comments(&ToggleComments::default(), window, cx)); + + cx.assert_editor_state(indoc! {" + fn a() { + «b(); + ˇ»«c(); + ˇ» d(); + } + "}); + + // The comment prefix is inserted at the same column for every line in a + // selection. + cx.update_editor(|e, window, cx| e.toggle_comments(&ToggleComments::default(), window, cx)); + + cx.assert_editor_state(indoc! {" + fn a() { + // «b(); + ˇ»// «c(); + ˇ» // d(); + } + "}); + + // If a selection ends at the beginning of a line, that line is not toggled. + cx.set_selections_state(indoc! {" + fn a() { + // b(); + «// c(); + ˇ» // d(); + } + "}); + + cx.update_editor(|e, window, cx| e.toggle_comments(&ToggleComments::default(), window, cx)); + + cx.assert_editor_state(indoc! {" + fn a() { + // b(); + «c(); + ˇ» // d(); + } + "}); + + // If a selection span a single line and is empty, the line is toggled. + cx.set_state(indoc! {" + fn a() { + a(); + b(); + ˇ + } + "}); + + cx.update_editor(|e, window, cx| e.toggle_comments(&ToggleComments::default(), window, cx)); + + cx.assert_editor_state(indoc! {" + fn a() { + a(); + b(); + //•ˇ + } + "}); + + // If a selection spans multiple lines, empty lines are also toggled. + cx.set_state(indoc! {" + fn a() { + «a(); + + c();ˇ» + } + "}); + + cx.update_editor(|e, window, cx| e.toggle_comments(&ToggleComments::default(), window, cx)); + + cx.assert_editor_state(indoc! {" + fn a() { + // «a(); + //• + // c();ˇ» + } + "}); + + // If a selection includes multiple comment prefixes, all lines are uncommented. + cx.set_state(indoc! {" + fn a() { + «// a(); + /// b(); + //! c();ˇ» + } + "}); + + cx.update_editor(|e, window, cx| e.toggle_comments(&ToggleComments::default(), window, cx)); + + cx.assert_editor_state(indoc! {" + fn a() { + «a(); + b(); + c();ˇ» + } + "}); +} + +#[gpui::test] +async fn test_toggle_comment_ignore_indent(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + let language = Arc::new(Language::new( + LanguageConfig { + line_comments: vec!["// ".into(), "//! ".into(), "/// ".into()], + ..Default::default() + }, + Some(tree_sitter_rust::LANGUAGE.into()), + )); + cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); + + let toggle_comments = &ToggleComments { + advance_downwards: false, + ignore_indent: true, + comment_empty_lines: false, + }; + + // If multiple selections intersect a line, the line is only toggled once. + cx.set_state(indoc! {" + fn a() { + // «b(); + // c(); + // ˇ» d(); + } + "}); + + cx.update_editor(|e, window, cx| e.toggle_comments(toggle_comments, window, cx)); + + cx.assert_editor_state(indoc! {" + fn a() { + «b(); + c(); + ˇ» d(); + } + "}); + + // The comment prefix is inserted at the beginning of each line + cx.update_editor(|e, window, cx| e.toggle_comments(toggle_comments, window, cx)); + + cx.assert_editor_state(indoc! {" + fn a() { + // «b(); + // c(); + // ˇ» d(); + } + "}); + + // If a selection ends at the beginning of a line, that line is not toggled. + cx.set_selections_state(indoc! {" + fn a() { + // b(); + // «c(); + ˇ»// d(); + } + "}); + + cx.update_editor(|e, window, cx| e.toggle_comments(toggle_comments, window, cx)); + + cx.assert_editor_state(indoc! {" + fn a() { + // b(); + «c(); + ˇ»// d(); + } + "}); + + // If a selection span a single line and is empty, the line is toggled. + cx.set_state(indoc! {" + fn a() { + a(); + b(); + ˇ + } + "}); + + cx.update_editor(|e, window, cx| e.toggle_comments(toggle_comments, window, cx)); + + cx.assert_editor_state(indoc! {" + fn a() { + a(); + b(); + //ˇ + } + "}); + + // If a selection span multiple lines, empty lines are not toggled. + cx.set_state(indoc! {" + fn a() { + «a(); + + c();ˇ» + } + "}); + + cx.update_editor(|e, window, cx| e.toggle_comments(toggle_comments, window, cx)); + + cx.assert_editor_state(indoc! {" + fn a() { + // «a(); + + // c();ˇ» + } + "}); + + // If a selection includes multiple comment prefixes, all lines are uncommented. + cx.set_state(indoc! {" + fn a() { + // «a(); + /// b(); + //! c();ˇ» + } + "}); + + cx.update_editor(|e, window, cx| e.toggle_comments(toggle_comments, window, cx)); + + cx.assert_editor_state(indoc! {" + fn a() { + «a(); + b(); + c();ˇ» + } + "}); +} + +#[gpui::test] +async fn test_toggle_comment_commenting_blank_lines(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + // A prefix without a trailing space keeps every expectation below free of + // trailing whitespace, which would otherwise be stripped on save and break + // the assertions on commented blank lines. + let language = Arc::new(Language::new( + LanguageConfig { + line_comments: vec!["//".into()], + ..Default::default() + }, + Some(tree_sitter_rust::LANGUAGE.into()), + )); + cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); + + let toggle_comments = &ToggleComments::default(); + + cx.set_state(indoc! {" + «fn a() { + b(); + + c(); + }ˇ» + "}); + + cx.update_editor(|e, window, cx| e.toggle_comments(toggle_comments, window, cx)); + + cx.assert_editor_state(indoc! {" + //«fn a() { + // b(); + // + // c(); + //}ˇ» + "}); + + // Toggling again removes the prefix from every line, blank ones included. + cx.update_editor(|e, window, cx| e.toggle_comments(toggle_comments, window, cx)); + + cx.assert_editor_state(indoc! {" + «fn a() { + b(); + + c(); + }ˇ» + "}); + + // All prefixes in a block go at one shared column so they line up: the + // smallest indent among the rows being commented. A blank line contributes + // 0 and drags that to 0 - shifting every line in the block left. VS Code + // does the same. + cx.set_state(indoc! {" + fn a() { + «b(); + + c();ˇ» + } + "}); + + cx.update_editor(|e, window, cx| e.toggle_comments(toggle_comments, window, cx)); + + cx.assert_editor_state(indoc! {" + fn a() { + // «b(); + // + // c();ˇ» + } + "}); +} + +#[gpui::test] +async fn test_toggle_comment_uncomments_after_parameter_change(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + let language = Arc::new(Language::new( + LanguageConfig { + line_comments: vec!["//".into()], + ..Default::default() + }, + Some(tree_sitter_rust::LANGUAGE.into()), + )); + cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); + + let skip_blank_lines = &ToggleComments { + comment_empty_lines: false, + ..Default::default() + }; + let comment_blank_lines = &ToggleComments::default(); + + cx.set_state(indoc! {" + «fn a() { + b(); + + c(); + }ˇ» + "}); + + // Comment with one binding: the blank line gets no marker. + cx.update_editor(|e, window, cx| e.toggle_comments(skip_blank_lines, window, cx)); + + cx.assert_editor_state(indoc! {" + //«fn a() { + // b(); + + // c(); + //}ˇ» + "}); + + // Toggle with the other binding. The blank line still has no marker, but it must + // not make the block look uncommented: the markers are removed, not doubled. + cx.update_editor(|e, window, cx| e.toggle_comments(comment_blank_lines, window, cx)); + + cx.assert_editor_state(indoc! {" + «fn a() { + b(); + + c(); + }ˇ» + "}); +} + +#[gpui::test] +async fn test_toggle_comment_selection_of_only_blank_lines(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + let language = Arc::new(Language::new( + LanguageConfig { + line_comments: vec!["//".into()], + ..Default::default() + }, + Some(tree_sitter_rust::LANGUAGE.into()), + )); + cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); + + let toggle_comments = &ToggleComments::default(); + + // No line carries a marker, so there is nothing to remove: comment them. + cx.set_state(indoc! {" + fn a() { + « + + ˇ»} + "}); + + cx.update_editor(|e, window, cx| e.toggle_comments(toggle_comments, window, cx)); + + cx.assert_editor_state(indoc! {" + fn a() { + //« + // + ˇ»} + "}); +} + +#[gpui::test] +async fn test_advance_downward_on_toggle_comment(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let language = Arc::new(Language::new( + LanguageConfig { + line_comments: vec!["// ".into()], + ..Default::default() + }, + Some(tree_sitter_rust::LANGUAGE.into()), + )); + + let mut cx = EditorTestContext::new(cx).await; + + cx.language_registry().add(language.clone()); + cx.update_buffer(|buffer, cx| { + buffer.set_language(Some(language), cx); + }); + + let toggle_comments = &ToggleComments { + advance_downwards: true, + ignore_indent: false, + comment_empty_lines: false, + }; + + // Single cursor on one line -> advance + // Cursor moves horizontally 3 characters as well on non-blank line + cx.set_state(indoc!( + "fn a() { + ˇdog(); + cat(); + }" + )); + cx.update_editor(|editor, window, cx| { + editor.toggle_comments(toggle_comments, window, cx); + }); + cx.assert_editor_state(indoc!( + "fn a() { + // dog(); + catˇ(); + }" + )); + + // Single selection on one line -> don't advance + cx.set_state(indoc!( + "fn a() { + «dog()ˇ»; + cat(); + }" + )); + cx.update_editor(|editor, window, cx| { + editor.toggle_comments(toggle_comments, window, cx); + }); + cx.assert_editor_state(indoc!( + "fn a() { + // «dog()ˇ»; + cat(); + }" + )); + + // Multiple cursors on one line -> advance + cx.set_state(indoc!( + "fn a() { + ˇdˇog(); + cat(); + }" + )); + cx.update_editor(|editor, window, cx| { + editor.toggle_comments(toggle_comments, window, cx); + }); + cx.assert_editor_state(indoc!( + "fn a() { + // dog(); + catˇ(ˇ); + }" + )); + + // Multiple cursors on one line, with selection -> don't advance + cx.set_state(indoc!( + "fn a() { + ˇdˇog«()ˇ»; + cat(); + }" + )); + cx.update_editor(|editor, window, cx| { + editor.toggle_comments(toggle_comments, window, cx); + }); + cx.assert_editor_state(indoc!( + "fn a() { + // ˇdˇog«()ˇ»; + cat(); + }" + )); + + // Single cursor on one line -> advance + // Cursor moves to column 0 on blank line + cx.set_state(indoc!( + "fn a() { + ˇdog(); + + cat(); + }" + )); + cx.update_editor(|editor, window, cx| { + editor.toggle_comments(toggle_comments, window, cx); + }); + cx.assert_editor_state(indoc!( + "fn a() { + // dog(); + ˇ + cat(); + }" + )); + + // Single cursor on one line -> advance + // Cursor starts and ends at column 0 + cx.set_state(indoc!( + "fn a() { + ˇ dog(); + cat(); + }" + )); + cx.update_editor(|editor, window, cx| { + editor.toggle_comments(toggle_comments, window, cx); + }); + cx.assert_editor_state(indoc!( + "fn a() { + // dog(); + ˇ cat(); + }" + )); +} + +#[gpui::test] +async fn test_toggle_block_comment(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + let html_language = Arc::new( + Language::new( + LanguageConfig { + name: "HTML".into(), + block_comment: Some(BlockCommentConfig { + start: "".into(), + tab_size: 0, + }), + ..Default::default() + }, + Some(tree_sitter_html::LANGUAGE.into()), + ) + .with_injection_query( + r#" + (script_element + (raw_text) @injection.content + (#set! injection.language "javascript")) + "#, + ) + .unwrap(), + ); + + let javascript_language = Arc::new(Language::new( + LanguageConfig { + name: "JavaScript".into(), + line_comments: vec!["// ".into()], + ..Default::default() + }, + Some(tree_sitter_typescript::LANGUAGE_TSX.into()), + )); + + cx.language_registry().add(html_language.clone()); + cx.language_registry().add(javascript_language); + cx.update_buffer(|buffer, cx| { + buffer.set_language(Some(html_language), cx); + }); + + // Toggle comments for empty selections + cx.set_state( + &r#" +

A

ˇ +

B

ˇ +

C

ˇ + "# + .unindent(), + ); + cx.update_editor(|editor, window, cx| { + editor.toggle_comments(&ToggleComments::default(), window, cx) + }); + cx.assert_editor_state( + &r#" + + + + "# + .unindent(), + ); + cx.update_editor(|editor, window, cx| { + editor.toggle_comments(&ToggleComments::default(), window, cx) + }); + cx.assert_editor_state( + &r#" +

A

ˇ +

B

ˇ +

C

ˇ + "# + .unindent(), + ); + + // Toggle comments for mixture of empty and non-empty selections, where + // multiple selections occupy a given line. + cx.set_state( + &r#" +

A«

+

ˇ»B

ˇ +

C«

+

ˇ»D

ˇ + "# + .unindent(), + ); + + cx.update_editor(|editor, window, cx| { + editor.toggle_comments(&ToggleComments::default(), window, cx) + }); + cx.assert_editor_state( + &r#" + + + "# + .unindent(), + ); + cx.update_editor(|editor, window, cx| { + editor.toggle_comments(&ToggleComments::default(), window, cx) + }); + cx.assert_editor_state( + &r#" +

A«

+

ˇ»B

ˇ +

C«

+

ˇ»D

ˇ + "# + .unindent(), + ); + + // Toggle comments when different languages are active for different + // selections. + cx.set_state( + &r#" + ˇ + "# + .unindent(), + ); + cx.executor().run_until_parked(); + cx.update_editor(|editor, window, cx| { + editor.toggle_comments(&ToggleComments::default(), window, cx) + }); + // TODO this is how it actually worked in Zed Stable, which is not very ergonomic. + // Uncommenting and commenting from this position brings in even more wrong artifacts. + cx.assert_editor_state( + &r#" + + // ˇvar x = new Y(); + + "# + .unindent(), + ); +} + +#[gpui::test] +fn test_editing_disjoint_excerpts(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let buffer = cx.new(|cx| Buffer::local(sample_text(6, 4, 'a'), cx)); + let multibuffer = cx.new(|cx| { + let mut multibuffer = MultiBuffer::new(ReadWrite); + multibuffer.set_excerpts_for_path( + PathKey::sorted(0), + buffer.clone(), + [ + Point::new(0, 0)..Point::new(0, 4), + Point::new(5, 0)..Point::new(5, 4), + ], + 0, + cx, + ); + assert_eq!(multibuffer.read(cx).text(), "aaaa\nffff"); + multibuffer + }); + + let (editor, cx) = cx.add_window_view(|window, cx| build_editor(multibuffer, window, cx)); + editor.update_in(cx, |editor, window, cx| { + assert_eq!(editor.text(cx), "aaaa\nffff"); + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_ranges([ + Point::new(0, 0)..Point::new(0, 0), + Point::new(1, 0)..Point::new(1, 0), + ]) + }); + + editor.handle_input("X", window, cx); + assert_eq!(editor.text(cx), "Xaaaa\nXffff"); + assert_eq!( + editor.selections.ranges(&editor.display_snapshot(cx)), + [ + Point::new(0, 1)..Point::new(0, 1), + Point::new(1, 1)..Point::new(1, 1), + ] + ); + + // Ensure the cursor's head is respected when deleting across an excerpt boundary. + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_ranges([Point::new(0, 2)..Point::new(1, 2)]) + }); + editor.backspace(&Default::default(), window, cx); + assert_eq!(editor.text(cx), "Xa\nfff"); + assert_eq!( + editor.selections.ranges(&editor.display_snapshot(cx)), + [Point::new(1, 0)..Point::new(1, 0)] + ); + + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_ranges([Point::new(1, 1)..Point::new(0, 1)]) + }); + editor.backspace(&Default::default(), window, cx); + assert_eq!(editor.text(cx), "X\nff"); + assert_eq!( + editor.selections.ranges(&editor.display_snapshot(cx)), + [Point::new(0, 1)..Point::new(0, 1)] + ); + }); +} + +#[gpui::test] +fn test_header_jump_data_uses_selection_excerpt(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + // 25-line buffer so excerpts at rows 1, 10, and 20 (each a 1-line range, + // expanded by 2 context lines) can't merge into a single excerpt. + let buffer_text = (0..25) + .map(|row| format!("line {row}")) + .collect::>() + .join("\n"); + let buffer = cx.new(|cx| Buffer::local(buffer_text, cx)); + let buffer_id = buffer.read_with(cx, |buffer, _| buffer.remote_id()); + + let multibuffer = cx.new(|cx| { + let mut multibuffer = MultiBuffer::new(ReadWrite); + multibuffer.set_excerpts_for_path( + PathKey::sorted(0), + buffer.clone(), + [ + Point::new(1, 0)..Point::new(1, 0), + Point::new(10, 0)..Point::new(10, 0), + Point::new(20, 0)..Point::new(20, 0), + ], + 2, + cx, + ); + multibuffer + }); + + let (editor, cx) = cx.add_window_view(|window, cx| build_editor(multibuffer, window, cx)); + + editor.update_in(cx, |editor, window, cx| { + let snapshot = editor.snapshot(window, cx); + let display_snapshot = editor.display_snapshot(cx); + + // Ensure the three ranges landed in three separate excerpts. + let excerpts: Vec<_> = snapshot + .buffer_snapshot() + .excerpts_for_buffer(buffer_id) + .collect(); + assert_eq!(excerpts.len(), 3); + + // Place the cursor at the start of the third excerpt, expressed in + // terms of the underlying buffer. + let selection_buffer_row = 20; + let buffer_entity = editor.buffer().read(cx).buffer(buffer_id).unwrap(); + let selection_anchor = editor.buffer().update(cx, |multibuffer, cx| { + multibuffer + .buffer_point_to_anchor(&buffer_entity, Point::new(selection_buffer_row, 0), cx) + .expect("buffer row 20 maps to a multibuffer anchor") + }); + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_anchor_ranges([selection_anchor..selection_anchor]) + }); + + let mut latest_selection_anchors: HashMap = HashMap::default(); + for selection in editor.selections.all_anchors(&display_snapshot).iter() { + let head = selection.head(); + if let Some((text_anchor, _)) = snapshot.buffer_snapshot().anchor_to_buffer_anchor(head) + { + latest_selection_anchors.insert(text_anchor.buffer_id, head); + } + } + + // The sticky buffer header represents the FIRST excerpt of its buffer, + // even when the cursor is in a later excerpt. That mismatch is the + // precondition for the regression. + let first_excerpt = snapshot + .buffer_snapshot() + .excerpt_boundaries_in_range(MultiBufferOffset(0)..snapshot.buffer_snapshot().len()) + .next() + .expect("multibuffer has at least one excerpt") + .next; + + let jump_data = header_jump_data( + &snapshot, + DisplayRow(0), + FILE_HEADER_HEIGHT + MULTI_BUFFER_EXCERPT_HEADER_HEIGHT, + &first_excerpt, + &latest_selection_anchors, + ); + + match jump_data { + JumpData::MultiBufferPoint { + position, + line_offset_from_top, + .. + } => { + assert_eq!( + position.row, selection_buffer_row, + "jump should target the cursor's buffer row, not the first excerpt's row" + ); + assert!( + line_offset_from_top < selection_buffer_row, + "line_offset_from_top ({line_offset_from_top}) should be measured from the \ + selection's excerpt, not the first excerpt; expected less than \ + selection_buffer_row ({selection_buffer_row})" + ); + } + JumpData::MultiBufferRow { .. } => { + panic!("expected MultiBufferPoint jump data when a selection is present") + } + } + }); +} + +#[gpui::test] +async fn test_extra_newline_insertion(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let language = Arc::new( + Language::new( + LanguageConfig { + brackets: BracketPairConfig { + pairs: vec![ + BracketPair { + start: "{".to_string(), + end: "}".to_string(), + close: true, + surround: true, + newline: true, + }, + BracketPair { + start: "/* ".to_string(), + end: " */".to_string(), + close: true, + surround: true, + newline: true, + }, + ], + ..Default::default() + }, + ..Default::default() + }, + Some(tree_sitter_rust::LANGUAGE.into()), + ) + .with_indents_query("") + .unwrap(), + ); + + let text = concat!( + "{ }\n", // + " x\n", // + " /* */\n", // + "x\n", // + "{{} }\n", // + ); + + let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(language, cx)); + let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + let (editor, cx) = cx.add_window_view(|window, cx| build_editor(buffer, window, cx)); + editor + .condition::(cx, |editor, cx| !editor.buffer.read(cx).is_parsing(cx)) + .await; + + editor.update_in(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(0), 2)..DisplayPoint::new(DisplayRow(0), 3), + DisplayPoint::new(DisplayRow(2), 5)..DisplayPoint::new(DisplayRow(2), 5), + DisplayPoint::new(DisplayRow(4), 4)..DisplayPoint::new(DisplayRow(4), 4), + ]) + }); + editor.newline(&Newline, window, cx); + + assert_eq!( + editor.buffer().read(cx).read(cx).text(), + concat!( + "{ \n", // Suppress rustfmt + "\n", // + "}\n", // + " x\n", // + " /* \n", // + " \n", // + " */\n", // + "x\n", // + "{{} \n", // + "}\n", // + ) + ); + }); +} + +#[gpui::test] +fn test_highlighted_ranges(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let editor = cx.add_window(|window, cx| { + let buffer = MultiBuffer::build_simple(&sample_text(16, 8, 'a'), cx); + build_editor(buffer, window, cx) + }); + + _ = editor.update(cx, |editor, window, cx| { + let buffer = editor.buffer.read(cx).snapshot(cx); + + let anchor_range = + |range: Range| buffer.anchor_after(range.start)..buffer.anchor_after(range.end); + + editor.highlight_background( + HighlightKey::ColorizeBracket(0), + &[ + anchor_range(Point::new(2, 1)..Point::new(2, 3)), + anchor_range(Point::new(4, 2)..Point::new(4, 4)), + anchor_range(Point::new(6, 3)..Point::new(6, 5)), + anchor_range(Point::new(8, 4)..Point::new(8, 6)), + ], + |_, _| Hsla::red(), + cx, + ); + editor.highlight_background( + HighlightKey::ColorizeBracket(1), + &[ + anchor_range(Point::new(3, 2)..Point::new(3, 5)), + anchor_range(Point::new(5, 3)..Point::new(5, 6)), + anchor_range(Point::new(7, 4)..Point::new(7, 7)), + anchor_range(Point::new(9, 5)..Point::new(9, 8)), + ], + |_, _| Hsla::green(), + cx, + ); + + let snapshot = editor.snapshot(window, cx); + let highlighted_ranges = editor.sorted_background_highlights_in_range( + anchor_range(Point::new(3, 4)..Point::new(7, 4)), + &snapshot, + cx.theme(), + ); + assert_eq!( + highlighted_ranges, + &[ + ( + DisplayPoint::new(DisplayRow(3), 2)..DisplayPoint::new(DisplayRow(3), 5), + Hsla::green(), + ), + ( + DisplayPoint::new(DisplayRow(4), 2)..DisplayPoint::new(DisplayRow(4), 4), + Hsla::red(), + ), + ( + DisplayPoint::new(DisplayRow(5), 3)..DisplayPoint::new(DisplayRow(5), 6), + Hsla::green(), + ), + ( + DisplayPoint::new(DisplayRow(6), 3)..DisplayPoint::new(DisplayRow(6), 5), + Hsla::red(), + ), + ] + ); + assert_eq!( + editor.sorted_background_highlights_in_range( + anchor_range(Point::new(5, 6)..Point::new(6, 4)), + &snapshot, + cx.theme(), + ), + &[( + DisplayPoint::new(DisplayRow(6), 3)..DisplayPoint::new(DisplayRow(6), 5), + Hsla::red(), + )] + ); + }); +} + +#[gpui::test] +async fn test_copy_highlight_json(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + cx.set_state(indoc! {" + fn main() { + let x = 1;ˇ + } + "}); + setup_syntax_highlighting(rust_lang(), &mut cx); + + cx.update_editor(|editor, window, cx| { + editor.copy_highlight_json(&CopyHighlightJson, window, cx); + }); + + let clipboard_json: serde_json::Value = + serde_json::from_str(&cx.read_from_clipboard().unwrap().text().unwrap()).unwrap(); + assert_eq!( + clipboard_json, + json!([ + [ + {"text": "fn", "highlight": "keyword"}, + {"text": " ", "highlight": null}, + {"text": "main", "highlight": "function"}, + {"text": "()", "highlight": "punctuation.bracket"}, + {"text": " ", "highlight": null}, + {"text": "{", "highlight": "punctuation.bracket"}, + ], + [ + {"text": " ", "highlight": null}, + {"text": "let", "highlight": "keyword"}, + {"text": " ", "highlight": null}, + {"text": "x", "highlight": "variable"}, + {"text": " ", "highlight": null}, + {"text": "=", "highlight": "operator"}, + {"text": " ", "highlight": null}, + {"text": "1", "highlight": "number"}, + {"text": ";", "highlight": "punctuation.delimiter"}, + ], + [ + {"text": "}", "highlight": "punctuation.bracket"}, + ], + ]) + ); +} + +#[gpui::test] +async fn test_copy_highlight_json_selected_range(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + cx.set_state(indoc! {" + fn main() { + «let x = 1; + let yˇ» = 2; + } + "}); + setup_syntax_highlighting(rust_lang(), &mut cx); + + cx.update_editor(|editor, window, cx| { + editor.copy_highlight_json(&CopyHighlightJson, window, cx); + }); + + let clipboard_json: serde_json::Value = + serde_json::from_str(&cx.read_from_clipboard().unwrap().text().unwrap()).unwrap(); + assert_eq!( + clipboard_json, + json!([ + [ + {"text": "let", "highlight": "keyword"}, + {"text": " ", "highlight": null}, + {"text": "x", "highlight": "variable"}, + {"text": " ", "highlight": null}, + {"text": "=", "highlight": "operator"}, + {"text": " ", "highlight": null}, + {"text": "1", "highlight": "number"}, + {"text": ";", "highlight": "punctuation.delimiter"}, + ], + [ + {"text": " ", "highlight": null}, + {"text": "let", "highlight": "keyword"}, + {"text": " ", "highlight": null}, + {"text": "y", "highlight": "variable"}, + ], + ]) + ); +} + +#[gpui::test] +async fn test_copy_highlight_json_selected_line_range(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + cx.set_state(indoc! {" + fn main() { + «let x = 1; + let yˇ» = 2; + } + "}); + setup_syntax_highlighting(rust_lang(), &mut cx); + + cx.update_editor(|editor, window, cx| { + editor.selections.set_line_mode(true); + editor.copy_highlight_json(&CopyHighlightJson, window, cx); + }); + + let clipboard_json: serde_json::Value = + serde_json::from_str(&cx.read_from_clipboard().unwrap().text().unwrap()).unwrap(); + assert_eq!( + clipboard_json, + json!([ + [ + {"text": " ", "highlight": null}, + {"text": "let", "highlight": "keyword"}, + {"text": " ", "highlight": null}, + {"text": "x", "highlight": "variable"}, + {"text": " ", "highlight": null}, + {"text": "=", "highlight": "operator"}, + {"text": " ", "highlight": null}, + {"text": "1", "highlight": "number"}, + {"text": ";", "highlight": "punctuation.delimiter"}, + ], + [ + {"text": " ", "highlight": null}, + {"text": "let", "highlight": "keyword"}, + {"text": " ", "highlight": null}, + {"text": "y", "highlight": "variable"}, + {"text": " ", "highlight": null}, + {"text": "=", "highlight": "operator"}, + {"text": " ", "highlight": null}, + {"text": "2", "highlight": "number"}, + {"text": ";", "highlight": "punctuation.delimiter"}, + ], + ]) + ); +} + +#[gpui::test] +async fn test_copy_highlight_json_single_line(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + cx.set_state(indoc! {" + fn main() { + let ˇx = 1; + let y = 2; + } + "}); + setup_syntax_highlighting(rust_lang(), &mut cx); + + cx.update_editor(|editor, window, cx| { + editor.selections.set_line_mode(true); + editor.copy_highlight_json(&CopyHighlightJson, window, cx); + }); + + let clipboard_json: serde_json::Value = + serde_json::from_str(&cx.read_from_clipboard().unwrap().text().unwrap()).unwrap(); + assert_eq!( + clipboard_json, + json!([ + [ + {"text": " ", "highlight": null}, + {"text": "let", "highlight": "keyword"}, + {"text": " ", "highlight": null}, + {"text": "x", "highlight": "variable"}, + {"text": " ", "highlight": null}, + {"text": "=", "highlight": "operator"}, + {"text": " ", "highlight": null}, + {"text": "1", "highlight": "number"}, + {"text": ";", "highlight": "punctuation.delimiter"}, + ] + ]) + ); +} + +#[gpui::test] +async fn test_following(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, ["/file.rs".as_ref()], cx).await; + + let buffer = project.update(cx, |project, cx| { + let buffer = project.create_local_buffer(&sample_text(16, 8, 'a'), None, false, cx); + cx.new(|cx| MultiBuffer::singleton(buffer, cx)) + }); + let leader = cx.add_window(|window, cx| build_editor(buffer.clone(), window, cx)); + let follower = cx.update(|cx| { + cx.open_window( + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(Bounds::from_corners( + gpui::Point::new(px(0.), px(0.)), + gpui::Point::new(px(10.), px(80.)), + ))), + ..Default::default() + }, + |window, cx| cx.new(|cx| build_editor(buffer.clone(), window, cx)), + ) + .unwrap() + }); + + let is_still_following = Rc::new(RefCell::new(true)); + let follower_edit_event_count = Rc::new(RefCell::new(0)); + let pending_update = Rc::new(RefCell::new(None)); + let leader_entity = leader.root(cx).unwrap(); + let follower_entity = follower.root(cx).unwrap(); + _ = follower.update(cx, { + let update = pending_update.clone(); + let is_still_following = is_still_following.clone(); + let follower_edit_event_count = follower_edit_event_count.clone(); + |_, window, cx| { + cx.subscribe_in( + &leader_entity, + window, + move |_, leader, event, window, cx| { + leader.update(cx, |leader, cx| { + leader.add_event_to_update_proto( + event, + &mut update.borrow_mut(), + window, + cx, + ); + }); + }, + ) + .detach(); + + cx.subscribe_in( + &follower_entity, + window, + move |_, _, event: &EditorEvent, _window, _cx| { + if matches!(Editor::to_follow_event(event), Some(FollowEvent::Unfollow)) { + *is_still_following.borrow_mut() = false; + } + + if let EditorEvent::BufferEdited = event { + *follower_edit_event_count.borrow_mut() += 1; + } + }, + ) + .detach(); + } + }); + + // Update the selections only + _ = leader.update(cx, |leader, window, cx| { + leader.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_ranges([MultiBufferOffset(1)..MultiBufferOffset(1)]) + }); + }); + follower + .update(cx, |follower, window, cx| { + follower.apply_update_proto( + &project, + pending_update.borrow_mut().take().unwrap(), + window, + cx, + ) + }) + .unwrap() + .await + .unwrap(); + _ = follower.update(cx, |follower, _, cx| { + assert_eq!( + follower.selections.ranges(&follower.display_snapshot(cx)), + vec![MultiBufferOffset(1)..MultiBufferOffset(1)] + ); + }); + assert!(*is_still_following.borrow()); + assert_eq!(*follower_edit_event_count.borrow(), 0); + + // Update the scroll position only + _ = leader.update(cx, |leader, window, cx| { + leader.set_scroll_position(gpui::Point::new(1.5, 3.5), window, cx); + }); + follower + .update(cx, |follower, window, cx| { + follower.apply_update_proto( + &project, + pending_update.borrow_mut().take().unwrap(), + window, + cx, + ) + }) + .unwrap() + .await + .unwrap(); + assert_eq!( + follower + .update(cx, |follower, _, cx| follower.scroll_position(cx)) + .unwrap(), + gpui::Point::new(1.5, 3.5) + ); + assert!(*is_still_following.borrow()); + assert_eq!(*follower_edit_event_count.borrow(), 0); + + // Update the selections and scroll position. The follower's scroll position is updated + // via autoscroll, not via the leader's exact scroll position. + _ = leader.update(cx, |leader, window, cx| { + leader.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_ranges([MultiBufferOffset(0)..MultiBufferOffset(0)]) + }); + leader.request_autoscroll(Autoscroll::newest(), cx); + leader.set_scroll_position(gpui::Point::new(1.5, 3.5), window, cx); + }); + follower + .update(cx, |follower, window, cx| { + follower.apply_update_proto( + &project, + pending_update.borrow_mut().take().unwrap(), + window, + cx, + ) + }) + .unwrap() + .await + .unwrap(); + _ = follower.update(cx, |follower, _, cx| { + assert_eq!(follower.scroll_position(cx), gpui::Point::new(1.5, 0.0)); + assert_eq!( + follower.selections.ranges(&follower.display_snapshot(cx)), + vec![MultiBufferOffset(0)..MultiBufferOffset(0)] + ); + }); + assert!(*is_still_following.borrow()); + + // Creating a pending selection that precedes another selection + _ = leader.update(cx, |leader, window, cx| { + leader.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_ranges([MultiBufferOffset(1)..MultiBufferOffset(1)]) + }); + leader.begin_selection(DisplayPoint::new(DisplayRow(0), 0), true, 1, window, cx); + }); + follower + .update(cx, |follower, window, cx| { + follower.apply_update_proto( + &project, + pending_update.borrow_mut().take().unwrap(), + window, + cx, + ) + }) + .unwrap() + .await + .unwrap(); + _ = follower.update(cx, |follower, _, cx| { + assert_eq!( + follower.selections.ranges(&follower.display_snapshot(cx)), + vec![ + MultiBufferOffset(0)..MultiBufferOffset(0), + MultiBufferOffset(1)..MultiBufferOffset(1) + ] + ); + }); + assert!(*is_still_following.borrow()); + + // Extend the pending selection so that it surrounds another selection + _ = leader.update(cx, |leader, window, cx| { + leader.extend_selection(DisplayPoint::new(DisplayRow(0), 2), 1, window, cx); + }); + follower + .update(cx, |follower, window, cx| { + follower.apply_update_proto( + &project, + pending_update.borrow_mut().take().unwrap(), + window, + cx, + ) + }) + .unwrap() + .await + .unwrap(); + _ = follower.update(cx, |follower, _, cx| { + assert_eq!( + follower.selections.ranges(&follower.display_snapshot(cx)), + vec![MultiBufferOffset(0)..MultiBufferOffset(2)] + ); + }); + + // Scrolling locally breaks the follow + _ = follower.update(cx, |follower, window, cx| { + let top_anchor = follower + .buffer() + .read(cx) + .read(cx) + .anchor_after(MultiBufferOffset(0)); + follower.set_scroll_anchor( + ScrollAnchor { + anchor: top_anchor, + offset: gpui::Point::new(0.0, 0.5), + }, + window, + cx, + ); + }); + assert!(!(*is_still_following.borrow())); +} + +#[gpui::test] +async fn test_following_with_multiple_excerpts(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, ["/file.rs".as_ref()], cx).await; + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone()); + + let cx = &mut VisualTestContext::from_window(*window, cx); + + let leader = pane.update_in(cx, |_, window, cx| { + let multibuffer = cx.new(|_| MultiBuffer::new(ReadWrite)); + cx.new(|cx| build_editor(multibuffer.clone(), window, cx)) + }); + + // Start following the editor when it has no excerpts. + let mut state_message = + leader.update_in(cx, |leader, window, cx| leader.to_state_proto(window, cx)); + let workspace_entity = workspace.clone(); + let follower_1 = cx + .update_window(*window, |_, window, cx| { + Editor::from_state_proto( + workspace_entity, + ViewId { + creator: CollaboratorId::PeerId(PeerId::default()), + id: 0, + }, + &mut state_message, + window, + cx, + ) + }) + .unwrap() + .unwrap() + .await + .unwrap(); + + let update_message = Rc::new(RefCell::new(None)); + follower_1.update_in(cx, { + let update = update_message.clone(); + |_, window, cx| { + cx.subscribe_in(&leader, window, move |_, leader, event, window, cx| { + leader.update(cx, |leader, cx| { + leader.add_event_to_update_proto(event, &mut update.borrow_mut(), window, cx); + }); + }) + .detach(); + } + }); + + let (buffer_1, buffer_2) = project.update(cx, |project, cx| { + ( + project.create_local_buffer("abc\ndef\nghi\njkl\nmno\npqr\nstu\nvwx\nyza\nbcd\nefg\nhij\nklm\nnop\nqrs\ntuv\nwxy\nzab\ncde\nfgh\n", None, false, cx), + project.create_local_buffer("aaa\nbbb\nccc\nddd\neee\nfff\nggg\nhhh\niii\njjj\nkkk\nlll\nmmm\nnnn\nooo\nppp\nqqq\nrrr\nsss\nttt\n", None, false, cx), + ) + }); + + // Insert some excerpts. + leader.update(cx, |leader, cx| { + leader.buffer.update(cx, |multibuffer, cx| { + multibuffer.set_excerpts_for_path( + PathKey::with_sort_prefix(1, rel_path("b.txt").into_arc()), + buffer_1.clone(), + vec![ + Point::row_range(0..3), + Point::row_range(1..6), + Point::row_range(12..15), + ], + 0, + cx, + ); + multibuffer.set_excerpts_for_path( + PathKey::with_sort_prefix(1, rel_path("a.txt").into_arc()), + buffer_2.clone(), + vec![Point::row_range(0..6), Point::row_range(8..12)], + 0, + cx, + ); + }); + }); + + // Apply the update of adding the excerpts. + follower_1 + .update_in(cx, |follower, window, cx| { + follower.apply_update_proto( + &project, + update_message.borrow().clone().unwrap(), + window, + cx, + ) + }) + .await + .unwrap(); + assert_eq!( + follower_1.update(cx, |editor, cx| editor.text(cx)), + leader.update(cx, |editor, cx| editor.text(cx)) + ); + update_message.borrow_mut().take(); + + // Start following separately after it already has excerpts. + let mut state_message = + leader.update_in(cx, |leader, window, cx| leader.to_state_proto(window, cx)); + let workspace_entity = workspace.clone(); + let follower_2 = cx + .update_window(*window, |_, window, cx| { + Editor::from_state_proto( + workspace_entity, + ViewId { + creator: CollaboratorId::PeerId(PeerId::default()), + id: 0, + }, + &mut state_message, + window, + cx, + ) + }) + .unwrap() + .unwrap() + .await + .unwrap(); + assert_eq!( + follower_2.update(cx, |editor, cx| editor.text(cx)), + leader.update(cx, |editor, cx| editor.text(cx)) + ); + + // Remove some excerpts. + leader.update(cx, |leader, cx| { + leader.buffer.update(cx, |multibuffer, cx| { + multibuffer.remove_excerpts( + PathKey::with_sort_prefix(1, rel_path("b.txt").into_arc()), + cx, + ); + }); + }); + + // Apply the update of removing the excerpts. + follower_1 + .update_in(cx, |follower, window, cx| { + follower.apply_update_proto( + &project, + update_message.borrow().clone().unwrap(), + window, + cx, + ) + }) + .await + .unwrap(); + follower_2 + .update_in(cx, |follower, window, cx| { + follower.apply_update_proto( + &project, + update_message.borrow().clone().unwrap(), + window, + cx, + ) + }) + .await + .unwrap(); + update_message.borrow_mut().take(); + assert_eq!( + follower_1.update(cx, |editor, cx| editor.text(cx)), + leader.update(cx, |editor, cx| editor.text(cx)) + ); +} + +#[gpui::test] +async fn go_to_prev_overlapping_diagnostic(executor: BackgroundExecutor, cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + let lsp_store = + cx.update_editor(|editor, _, cx| editor.project().unwrap().read(cx).lsp_store()); + + cx.set_state(indoc! {" + ˇfn func(abc def: i32) -> u32 { + } + "}); + + cx.update(|_, cx| { + lsp_store.update(cx, |lsp_store, cx| { + lsp_store + .update_diagnostics( + LanguageServerId(0), + lsp::PublishDiagnosticsParams { + uri: lsp::Uri::from_file_path(path!("/root/file")).unwrap(), + version: None, + diagnostics: vec![ + lsp::Diagnostic { + range: lsp::Range::new( + lsp::Position::new(0, 11), + lsp::Position::new(0, 12), + ), + severity: Some(lsp::DiagnosticSeverity::ERROR), + ..Default::default() + }, + lsp::Diagnostic { + range: lsp::Range::new( + lsp::Position::new(0, 12), + lsp::Position::new(0, 15), + ), + severity: Some(lsp::DiagnosticSeverity::ERROR), + ..Default::default() + }, + lsp::Diagnostic { + range: lsp::Range::new( + lsp::Position::new(0, 25), + lsp::Position::new(0, 28), + ), + severity: Some(lsp::DiagnosticSeverity::ERROR), + ..Default::default() + }, + ], + }, + None, + DiagnosticSourceKind::Pushed, + &[], + cx, + ) + .unwrap() + }); + }); + + executor.run_until_parked(); + + cx.update_editor(|editor, window, cx| { + editor.go_to_prev_diagnostic(&GoToPreviousDiagnostic::default(), window, cx); + }); + + cx.assert_editor_state(indoc! {" + fn func(abc def: i32) -> ˇu32 { + } + "}); + + cx.update_editor(|editor, window, cx| { + editor.go_to_prev_diagnostic(&GoToPreviousDiagnostic::default(), window, cx); + }); + + cx.assert_editor_state(indoc! {" + fn func(abc ˇdef: i32) -> u32 { + } + "}); + + cx.update_editor(|editor, window, cx| { + editor.go_to_prev_diagnostic(&GoToPreviousDiagnostic::default(), window, cx); + }); + + cx.assert_editor_state(indoc! {" + fn func(abcˇ def: i32) -> u32 { + } + "}); + + cx.update_editor(|editor, window, cx| { + editor.go_to_prev_diagnostic(&GoToPreviousDiagnostic::default(), window, cx); + }); + + cx.assert_editor_state(indoc! {" + fn func(abc def: i32) -> ˇu32 { + } + "}); +} + +#[gpui::test] +async fn go_to_diagnostic(executor: BackgroundExecutor, cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + let lsp_store = + cx.update_editor(|editor, _, cx| editor.project().unwrap().read(cx).lsp_store()); + + // Place the cursor inside the `def` diagnostic (`[12, 15)`) before any + // diagnostic is active so we can later confirm that running `editor: go to + // diagnostic` will activate this diagnostic instead of advancing to the + // next one. + cx.set_state(indoc! {" + fn func(abc dˇef: i32) -> u32 { + } + "}); + + // Set up the diagnostics: + // + // * `[11, 12)` (the space before `def`), + // * `[12, 15)` (`def`), + // * `[25, 28)` (`u32`). + cx.update(|_, cx| { + lsp_store.update(cx, |lsp_store, cx| { + lsp_store + .update_diagnostics( + LanguageServerId(0), + lsp::PublishDiagnosticsParams { + uri: lsp::Uri::from_file_path(path!("/root/file")).unwrap(), + version: None, + diagnostics: vec![ + lsp::Diagnostic { + range: lsp::Range::new( + lsp::Position::new(0, 11), + lsp::Position::new(0, 12), + ), + severity: Some(lsp::DiagnosticSeverity::ERROR), + ..Default::default() + }, + lsp::Diagnostic { + range: lsp::Range::new( + lsp::Position::new(0, 12), + lsp::Position::new(0, 15), + ), + severity: Some(lsp::DiagnosticSeverity::ERROR), + ..Default::default() + }, + lsp::Diagnostic { + range: lsp::Range::new( + lsp::Position::new(0, 25), + lsp::Position::new(0, 28), + ), + severity: Some(lsp::DiagnosticSeverity::ERROR), + ..Default::default() + }, + ], + }, + None, + DiagnosticSourceKind::Pushed, + &[], + cx, + ) + .unwrap() + }); + }); + + executor.run_until_parked(); + + // When the cursor is at an inactive diagnostic, cursor should be moved to + // the start of that same diagnostic and activate it. + cx.update_editor(|editor, window, cx| { + editor.go_to_diagnostic(&GoToDiagnostic::default(), window, cx); + }); + cx.assert_editor_state(indoc! {" + fn func(abc ˇdef: i32) -> u32 { + } + "}); + + cx.update_editor(|editor, window, cx| { + editor.go_to_diagnostic(&GoToDiagnostic::default(), window, cx); + }); + cx.assert_editor_state(indoc! {" + fn func(abc def: i32) -> ˇu32 { + } + "}); + + cx.update_editor(|editor, window, cx| { + editor.go_to_diagnostic(&GoToDiagnostic::default(), window, cx); + }); + cx.assert_editor_state(indoc! {" + fn func(abcˇ def: i32) -> u32 { + } + "}); + + // Manually move the cursor to a different, not yet active diagnostic to + // confirm that using `editor: go to diagnostic` will now activate this one. + cx.update_editor(|editor, window, cx| { + editor.change_selections(Default::default(), window, cx, |s| { + s.select_ranges([Point::new(0, 26)..Point::new(0, 26)]) + }); + }); + + cx.update_editor(|editor, window, cx| { + editor.go_to_diagnostic(&GoToDiagnostic::default(), window, cx); + }); + cx.assert_editor_state(indoc! {" + fn func(abc def: i32) -> ˇu32 { + } + "}); + + cx.update_editor(|editor, window, cx| { + editor.change_selections(Default::default(), window, cx, |s| { + s.select_ranges([Point::new(0, 0)..Point::new(0, 0)]) + }); + }); + cx.update_editor(|editor, window, cx| { + editor.go_to_diagnostic(&GoToDiagnostic::default(), window, cx); + }); + cx.assert_editor_state(indoc! {" + fn func(abcˇ def: i32) -> u32 { + } + "}); +} + +#[gpui::test] +async fn test_go_to_hunk(executor: BackgroundExecutor, cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + let diff_base = r#" + use some::mod; + + const A: u32 = 42; + + fn main() { + println!("hello"); + + println!("world"); + } + "# + .unindent(); + + // Edits are modified, removed, modified, added + cx.set_state( + &r#" + use some::modified; + + ˇ + fn main() { + println!("hello there"); + + println!("around the"); + println!("world"); + } + "# + .unindent(), + ); + + cx.set_head_text(&diff_base); + executor.run_until_parked(); + + cx.update_editor(|editor, window, cx| { + //Wrap around the bottom of the buffer + for _ in 0..3 { + editor.go_to_next_hunk(&GoToHunk, window, cx); + } + }); + + cx.assert_editor_state( + &r#" + ˇuse some::modified; + + + fn main() { + println!("hello there"); + + println!("around the"); + println!("world"); + } + "# + .unindent(), + ); + + cx.update_editor(|editor, window, cx| { + //Wrap around the top of the buffer + for _ in 0..2 { + editor.go_to_prev_hunk(&GoToPreviousHunk, window, cx); + } + }); + + cx.assert_editor_state( + &r#" + use some::modified; + + + fn main() { + ˇ println!("hello there"); + + println!("around the"); + println!("world"); + } + "# + .unindent(), + ); + + cx.update_editor(|editor, window, cx| { + editor.go_to_prev_hunk(&GoToPreviousHunk, window, cx); + }); + + cx.assert_editor_state( + &r#" + use some::modified; + + ˇ + fn main() { + println!("hello there"); + + println!("around the"); + println!("world"); + } + "# + .unindent(), + ); + + cx.update_editor(|editor, window, cx| { + editor.go_to_prev_hunk(&GoToPreviousHunk, window, cx); + }); + + cx.assert_editor_state( + &r#" + ˇuse some::modified; + + + fn main() { + println!("hello there"); + + println!("around the"); + println!("world"); + } + "# + .unindent(), + ); + + cx.update_editor(|editor, window, cx| { + for _ in 0..2 { + editor.go_to_prev_hunk(&GoToPreviousHunk, window, cx); + } + }); + + cx.assert_editor_state( + &r#" + use some::modified; + + + fn main() { + ˇ println!("hello there"); + + println!("around the"); + println!("world"); + } + "# + .unindent(), + ); + + cx.update_editor(|editor, window, cx| { + editor.fold(&Fold, window, cx); + }); + + cx.update_editor(|editor, window, cx| { + editor.go_to_next_hunk(&GoToHunk, window, cx); + }); + + cx.assert_editor_state( + &r#" + ˇuse some::modified; + + + fn main() { + println!("hello there"); + + println!("around the"); + println!("world"); + } + "# + .unindent(), + ); +} + +#[test] +fn test_split_words() { + fn split(text: &str) -> Vec<&str> { + split_words(text).collect() + } + + assert_eq!(split("HelloWorld"), &["Hello", "World"]); + assert_eq!(split("hello_world"), &["hello_", "world"]); + assert_eq!(split("_hello_world_"), &["_", "hello_", "world_"]); + assert_eq!(split("Hello_World"), &["Hello_", "World"]); + assert_eq!(split("helloWOrld"), &["hello", "WOrld"]); + assert_eq!(split("helloworld"), &["helloworld"]); + + assert_eq!(split(":do_the_thing"), &[":", "do_", "the_", "thing"]); +} + +#[test] +fn test_split_words_for_snippet_prefix() { + fn split(text: &str) -> Vec<&str> { + snippet_candidate_suffixes(text, &|c| c.is_alphanumeric() || c == '_').collect() + } + + assert_eq!(split("HelloWorld"), &["HelloWorld"]); + assert_eq!(split("hello_world"), &["hello_world"]); + assert_eq!(split("_hello_world_"), &["_hello_world_"]); + assert_eq!(split("Hello_World"), &["Hello_World"]); + assert_eq!(split("helloWOrld"), &["helloWOrld"]); + assert_eq!(split("helloworld"), &["helloworld"]); + assert_eq!( + split("this@is!@#$^many . symbols"), + &[ + "symbols", + " symbols", + ". symbols", + " . symbols", + " . symbols", + " . symbols", + "many . symbols", + "^many . symbols", + "$^many . symbols", + "#$^many . symbols", + "@#$^many . symbols", + "!@#$^many . symbols", + "is!@#$^many . symbols", + "@is!@#$^many . symbols", + "this@is!@#$^many . symbols", + ], + ); + assert_eq!(split("a.s"), &["s", ".s", "a.s"]); +} + +#[gpui::test] +async fn test_move_to_syntax_node_relative_jumps(tcx: &mut TestAppContext) { + init_test(tcx, |_| {}); + + let mut cx = EditorLspTestContext::new( + Arc::into_inner(markdown_lang()).unwrap(), + Default::default(), + tcx, + ) + .await; + + async fn assert(offset: i8, before: &str, after: &str, cx: &mut EditorLspTestContext) { + let _state_context = cx.set_state(before); + cx.run_until_parked(); + cx.update_editor(|editor, window, cx| editor.go_to_symbol_by_offset(window, cx, offset)) + .await + .unwrap(); + cx.run_until_parked(); + cx.assert_editor_state(after); + } + + const ABOVE: i8 = -1; + const BELOW: i8 = 1; + + assert( + ABOVE, + indoc! {" + # Foo + + ˇFoo foo foo + + # Bar + + Bar bar bar + "}, + indoc! {" + ˇ# Foo + + Foo foo foo + + # Bar + + Bar bar bar + "}, + &mut cx, + ) + .await; + + assert( + ABOVE, + indoc! {" + ˇ# Foo + + Foo foo foo + + # Bar + + Bar bar bar + "}, + indoc! {" + ˇ# Foo + + Foo foo foo + + # Bar + + Bar bar bar + "}, + &mut cx, + ) + .await; + + assert( + BELOW, + indoc! {" + ˇ# Foo + + Foo foo foo + + # Bar + + Bar bar bar + "}, + indoc! {" + # Foo + + Foo foo foo + + ˇ# Bar + + Bar bar bar + "}, + &mut cx, + ) + .await; + + assert( + BELOW, + indoc! {" + # Foo + + ˇFoo foo foo + + # Bar + + Bar bar bar + "}, + indoc! {" + # Foo + + Foo foo foo + + ˇ# Bar + + Bar bar bar + "}, + &mut cx, + ) + .await; + + assert( + BELOW, + indoc! {" + # Foo + + Foo foo foo + + ˇ# Bar + + Bar bar bar + "}, + indoc! {" + # Foo + + Foo foo foo + + ˇ# Bar + + Bar bar bar + "}, + &mut cx, + ) + .await; + + assert( + BELOW, + indoc! {" + # Foo + + Foo foo foo + + # Bar + ˇ + Bar bar bar + "}, + indoc! {" + # Foo + + Foo foo foo + + # Bar + ˇ + Bar bar bar + "}, + &mut cx, + ) + .await; +} + +#[gpui::test] +async fn test_move_to_syntax_node_relative_dead_zone(tcx: &mut TestAppContext) { + init_test(tcx, |_| {}); + + let mut cx = EditorLspTestContext::new( + Arc::into_inner(rust_lang()).unwrap(), + Default::default(), + tcx, + ) + .await; + + async fn assert(offset: i8, before: &str, after: &str, cx: &mut EditorLspTestContext) { + let _state_context = cx.set_state(before); + cx.run_until_parked(); + cx.update_editor(|editor, window, cx| editor.go_to_symbol_by_offset(window, cx, offset)) + .await + .unwrap(); + cx.run_until_parked(); + cx.assert_editor_state(after); + } + + const ABOVE: i8 = -1; + const BELOW: i8 = 1; + + assert( + ABOVE, + indoc! {" + fn foo() { + // foo fn + } + + ˇ// this zone is not inside any top level outline node + + fn bar() { + // bar fn + let _ = 2; + } + "}, + indoc! {" + ˇfn foo() { + // foo fn + } + + // this zone is not inside any top level outline node + + fn bar() { + // bar fn + let _ = 2; + } + "}, + &mut cx, + ) + .await; + + assert( + BELOW, + indoc! {" + fn foo() { + // foo fn + } + + ˇ// this zone is not inside any top level outline node + + fn bar() { + // bar fn + let _ = 2; + } + "}, + indoc! {" + fn foo() { + // foo fn + } + + // this zone is not inside any top level outline node + + ˇfn bar() { + // bar fn + let _ = 2; + } + "}, + &mut cx, + ) + .await; +} + +#[gpui::test] +async fn test_move_to_enclosing_bracket(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorLspTestContext::new_typescript(Default::default(), cx).await; + + #[track_caller] + fn assert(before: &str, after: &str, cx: &mut EditorLspTestContext) { + let _state_context = cx.set_state(before); + cx.run_until_parked(); + cx.update_editor(|editor, window, cx| { + editor.move_to_enclosing_bracket(&MoveToEnclosingBracket, window, cx) + }); + cx.run_until_parked(); + cx.assert_editor_state(after); + } + + // Outside bracket jumps to outside of matching bracket + assert("console.logˇ(var);", "console.log(var)ˇ;", &mut cx); + assert("console.log(var)ˇ;", "console.logˇ(var);", &mut cx); + + // Inside bracket jumps to inside of matching bracket + assert("console.log(ˇvar);", "console.log(varˇ);", &mut cx); + assert("console.log(varˇ);", "console.log(ˇvar);", &mut cx); + + // When outside a bracket and inside, favor jumping to the inside bracket + assert( + "console.log('foo', [1, 2, 3]ˇ);", + "console.log('foo', ˇ[1, 2, 3]);", + &mut cx, + ); + assert( + "console.log(ˇ'foo', [1, 2, 3]);", + "console.log('foo'ˇ, [1, 2, 3]);", + &mut cx, + ); + + // Bias forward if two options are equally likely + assert( + "let result = curried_fun()ˇ();", + "let result = curried_fun()()ˇ;", + &mut cx, + ); + + // If directly adjacent to a smaller pair but inside a larger (not adjacent), pick the smaller + assert( + indoc! {" + function test() { + console.log('test')ˇ + }"}, + indoc! {" + function test() { + console.logˇ('test') + }"}, + &mut cx, + ); +} + +#[gpui::test] +async fn test_move_to_enclosing_bracket_in_markdown_code_block(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let language_registry = Arc::new(language::LanguageRegistry::test(cx.executor())); + language_registry.add(markdown_lang()); + language_registry.add(rust_lang()); + let buffer = cx.new(|cx| { + let mut buffer = language::Buffer::local( + indoc! {" + ```rs + impl Worktree { + pub async fn open_buffers(&self, path: &Path) -> impl Iterator<&Buffer> { + } + } + ``` + "}, + cx, + ); + buffer.set_language_registry(language_registry.clone()); + buffer.set_language(Some(markdown_lang()), cx); + buffer + }); + let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + let editor = cx.add_window(|window, cx| build_editor(buffer.clone(), window, cx)); + cx.executor().run_until_parked(); + _ = editor.update(cx, |editor, window, cx| { + // Case 1: Test outer enclosing brackets + select_ranges( + editor, + &indoc! {" + ```rs + impl Worktree { + pub async fn open_buffers(&self, path: &Path) -> impl Iterator<&Buffer> { + } + }ˇ + ``` + "}, + window, + cx, + ); + editor.move_to_enclosing_bracket(&MoveToEnclosingBracket, window, cx); + assert_text_with_selections( + editor, + &indoc! {" + ```rs + impl Worktree ˇ{ + pub async fn open_buffers(&self, path: &Path) -> impl Iterator<&Buffer> { + } + } + ``` + "}, + cx, + ); + // Case 2: Test inner enclosing brackets + select_ranges( + editor, + &indoc! {" + ```rs + impl Worktree { + pub async fn open_buffers(&self, path: &Path) -> impl Iterator<&Buffer> { + }ˇ + } + ``` + "}, + window, + cx, + ); + editor.move_to_enclosing_bracket(&MoveToEnclosingBracket, window, cx); + assert_text_with_selections( + editor, + &indoc! {" + ```rs + impl Worktree { + pub async fn open_buffers(&self, path: &Path) -> impl Iterator<&Buffer> ˇ{ + } + } + ``` + "}, + cx, + ); + }); +} + +#[gpui::test] +async fn test_on_type_formatting_not_triggered(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/a"), + json!({ + "main.rs": "fn main() { let a = 5; }", + "other.rs": "// Test file", + }), + ) + .await; + let project = Project::test(fs, [path!("/a").as_ref()], cx).await; + + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(Arc::new(Language::new( + LanguageConfig { + name: "Rust".into(), + matcher: (LanguageMatcher { + path_suffixes: vec!["rs".to_string()], + ..Default::default() + }) + .into(), + brackets: BracketPairConfig { + pairs: vec![BracketPair { + start: "{".to_string(), + end: "}".to_string(), + close: true, + surround: true, + newline: true, + }], + disabled_scopes_by_bracket_ix: Vec::new(), + }, + ..Default::default() + }, + Some(tree_sitter_rust::LANGUAGE.into()), + ))); + let mut fake_servers = language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + capabilities: lsp::ServerCapabilities { + document_on_type_formatting_provider: Some(lsp::DocumentOnTypeFormattingOptions { + first_trigger_character: "{".to_string(), + more_trigger_character: None, + }), + ..Default::default() + }, + ..Default::default() + }, + ); + + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + + let cx = &mut VisualTestContext::from_window(*window, cx); + + let worktree_id = workspace.update_in(cx, |workspace, _, cx| { + workspace.project().update(cx, |project, cx| { + project.worktrees(cx).next().unwrap().read(cx).id() + }) + }); + + let buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/a/main.rs"), cx) + }) + .await + .unwrap(); + let editor_handle = workspace + .update_in(cx, |workspace, window, cx| { + workspace.open_path((worktree_id, rel_path("main.rs")), None, true, window, cx) + }) + .await + .unwrap() + .downcast::() + .unwrap(); + + let fake_server = fake_servers.next().await.unwrap(); + + fake_server.set_request_handler::( + |params, _| async move { + assert_eq!( + params.text_document_position.text_document.uri, + lsp::Uri::from_file_path(path!("/a/main.rs")).unwrap(), + ); + assert_eq!( + params.text_document_position.position, + lsp::Position::new(0, 21), + ); + + Ok(Some(vec![lsp::TextEdit { + new_text: "]".to_string(), + range: lsp::Range::new(lsp::Position::new(0, 22), lsp::Position::new(0, 22)), + }])) + }, + ); + + editor_handle.update_in(cx, |editor, window, cx| { + window.focus(&editor.focus_handle(cx), cx); + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_ranges([Point::new(0, 21)..Point::new(0, 20)]) + }); + editor.handle_input("{", window, cx); + }); + + cx.executor().run_until_parked(); + + buffer.update(cx, |buffer, _| { + assert_eq!( + buffer.text(), + "fn main() { let a = {5}; }", + "No extra braces from on type formatting should appear in the buffer" + ) + }); +} + +#[gpui::test(iterations = 20, seeds(31))] +async fn test_on_type_formatting_is_applied_after_autoindent(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + document_on_type_formatting_provider: Some(lsp::DocumentOnTypeFormattingOptions { + first_trigger_character: ".".to_string(), + more_trigger_character: None, + }), + ..Default::default() + }, + cx, + ) + .await; + + cx.update_buffer(|buffer, _| { + // This causes autoindent to be async. + buffer.set_sync_parse_timeout(None) + }); + + cx.set_state("fn c() {\n d()ˇ\n}\n"); + cx.simulate_keystroke("\n"); + cx.run_until_parked(); + + let buffer_cloned = cx.multibuffer(|multi_buffer, _| multi_buffer.as_singleton().unwrap()); + let mut request = + cx.set_request_handler::(move |_, _, mut cx| { + let buffer_cloned = buffer_cloned.clone(); + async move { + buffer_cloned.update(&mut cx, |buffer, _| { + assert_eq!( + buffer.text(), + "fn c() {\n d()\n .\n}\n", + "OnTypeFormatting should triggered after autoindent applied" + ) + }); + + Ok(Some(vec![])) + } + }); + + cx.simulate_keystroke("."); + cx.run_until_parked(); + + cx.assert_editor_state("fn c() {\n d()\n .ˇ\n}\n"); + assert!(request.next().await.is_some()); + request.close(); + assert!(request.next().await.is_none()); +} + +#[gpui::test] +async fn test_on_type_formatting_not_scheduled_without_server_support(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorLspTestContext::new_rust(lsp::ServerCapabilities::default(), cx).await; + cx.set_state("let a = bˇ;\n"); + + let task = cx.update_editor(|editor, window, cx| { + editor.trigger_on_type_formatting(".".to_owned(), window, cx) + }); + + assert!(task.is_none()); +} + +#[gpui::test] +async fn test_on_type_formatting_preserves_cursor_position(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + document_on_type_formatting_provider: Some(lsp::DocumentOnTypeFormattingOptions { + first_trigger_character: ".".to_string(), + more_trigger_character: None, + }), + ..Default::default() + }, + cx, + ) + .await; + + cx.set_state("let a = bˇ;\n"); + + let mut request = + cx.set_request_handler::(|_, params, _| async move { + assert_eq!( + params.text_document_position.position, + lsp::Position::new(0, 10) + ); + Ok(Some(vec![lsp::TextEdit { + range: lsp::Range::new(lsp::Position::new(0, 10), lsp::Position::new(0, 11)), + new_text: "c;".to_string(), + }])) + }); + + cx.simulate_keystroke("."); + cx.run_until_parked(); + + cx.assert_editor_state("let a = b.ˇc;\n"); + assert!(request.next().await.is_some()); +} + +#[gpui::test] +async fn test_on_type_formatting_does_not_rewind_over_intervening_edits(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + document_on_type_formatting_provider: Some(lsp::DocumentOnTypeFormattingOptions { + first_trigger_character: ".".to_string(), + more_trigger_character: None, + }), + ..Default::default() + }, + cx, + ) + .await; + + cx.set_state("let a = bˇ;\n"); + + let (request_started_tx, request_started_rx) = oneshot::channel(); + let (respond_tx, respond_rx) = oneshot::channel(); + let mut request_started_tx = Some(request_started_tx); + let mut respond_rx = Some(respond_rx); + let _request = + cx.set_request_handler::(move |_, _, _| { + request_started_tx.take().unwrap().send(()).unwrap(); + let respond_rx = respond_rx.take().unwrap(); + async move { + respond_rx.await.unwrap(); + Ok(Some(vec![lsp::TextEdit { + range: lsp::Range::new(lsp::Position::new(0, 10), lsp::Position::new(0, 10)), + new_text: "c".to_string(), + }])) + } + }); + + cx.simulate_keystroke("."); + request_started_rx.await.unwrap(); + cx.update_buffer(|buffer, cx| buffer.edit([(10..10, "x")], None, cx)); + respond_tx.send(()).unwrap(); + cx.run_until_parked(); + + cx.assert_editor_state("let a = b.cxˇ;\n"); +} + +#[gpui::test] +async fn test_language_server_restart_due_to_settings_change(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/a"), + json!({ + "main.rs": "fn main() { let a = 5; }", + "other.rs": "// Test file", + }), + ) + .await; + + let project = Project::test(fs, [path!("/a").as_ref()], cx).await; + + let server_restarts = Arc::new(AtomicUsize::new(0)); + let closure_restarts = Arc::clone(&server_restarts); + let language_server_name = "test language server"; + let language_name: LanguageName = "Rust".into(); + + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(Arc::new(Language::new( + LanguageConfig { + name: language_name.clone(), + matcher: (LanguageMatcher { + path_suffixes: vec!["rs".to_string()], + ..Default::default() + }) + .into(), + ..Default::default() + }, + Some(tree_sitter_rust::LANGUAGE.into()), + ))); + let mut fake_servers = language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + name: language_server_name, + initialization_options: Some(json!({ + "testOptionValue": true + })), + initializer: Some(Box::new(move |fake_server| { + let task_restarts = Arc::clone(&closure_restarts); + fake_server.set_request_handler::(move |_, _| { + task_restarts.fetch_add(1, atomic::Ordering::Release); + futures::future::ready(Ok(())) + }); + })), + ..Default::default() + }, + ); + + let _window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let _buffer = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/a/main.rs"), cx) + }) + .await + .unwrap(); + let _fake_server = fake_servers.next().await.unwrap(); + update_test_language_settings(cx, &|language_settings| { + language_settings.languages.0.insert( + language_name.clone().0.to_string(), + LanguageSettingsContent { + tab_size: NonZeroU32::new(8), + ..Default::default() + }, + ); + }); + cx.executor().run_until_parked(); + assert_eq!( + server_restarts.load(atomic::Ordering::Acquire), + 0, + "Should not restart LSP server on an unrelated change" + ); + + update_test_project_settings(cx, &|project_settings| { + project_settings.lsp.0.insert( + "Some other server name".into(), + LspSettings { + binary: None, + settings: None, + initialization_options: Some(json!({ + "some other init value": false + })), + enable_lsp_tasks: false, + fetch: None, + }, + ); + }); + cx.executor().run_until_parked(); + assert_eq!( + server_restarts.load(atomic::Ordering::Acquire), + 0, + "Should not restart LSP server on an unrelated LSP settings change" + ); + + update_test_project_settings(cx, &|project_settings| { + project_settings.lsp.0.insert( + language_server_name.into(), + LspSettings { + binary: None, + settings: None, + initialization_options: Some(json!({ + "anotherInitValue": false + })), + enable_lsp_tasks: false, + fetch: None, + }, + ); + }); + cx.executor().run_until_parked(); + assert_eq!( + server_restarts.load(atomic::Ordering::Acquire), + 1, + "Should restart LSP server on a related LSP settings change" + ); + + update_test_project_settings(cx, &|project_settings| { + project_settings.lsp.0.insert( + language_server_name.into(), + LspSettings { + binary: None, + settings: None, + initialization_options: Some(json!({ + "anotherInitValue": false + })), + enable_lsp_tasks: false, + fetch: None, + }, + ); + }); + cx.executor().run_until_parked(); + assert_eq!( + server_restarts.load(atomic::Ordering::Acquire), + 1, + "Should not restart LSP server on a related LSP settings change that is the same" + ); + + update_test_project_settings(cx, &|project_settings| { + project_settings.lsp.0.insert( + language_server_name.into(), + LspSettings { + binary: None, + settings: None, + initialization_options: None, + enable_lsp_tasks: false, + fetch: None, + }, + ); + }); + cx.executor().run_until_parked(); + assert_eq!( + server_restarts.load(atomic::Ordering::Acquire), + 2, + "Should restart LSP server on another related LSP settings change" + ); +} + +#[gpui::test] +async fn test_completions_with_additional_edits(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + completion_provider: Some(lsp::CompletionOptions { + trigger_characters: Some(vec![".".to_string()]), + resolve_provider: Some(true), + ..Default::default() + }), + ..Default::default() + }, + cx, + ) + .await; + + cx.set_state("fn main() { let a = 2ˇ; }"); + cx.simulate_keystroke("."); + let completion_item = lsp::CompletionItem { + label: "some".into(), + kind: Some(lsp::CompletionItemKind::SNIPPET), + detail: Some("Wrap the expression in an `Option::Some`".to_string()), + documentation: Some(lsp::Documentation::MarkupContent(lsp::MarkupContent { + kind: lsp::MarkupKind::Markdown, + value: "```rust\nSome(2)\n```".to_string(), + })), + deprecated: Some(false), + sort_text: Some("fffffff2".to_string()), + filter_text: Some("some".to_string()), + insert_text_format: Some(lsp::InsertTextFormat::SNIPPET), + text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit { + range: lsp::Range { + start: lsp::Position { + line: 0, + character: 22, + }, + end: lsp::Position { + line: 0, + character: 22, + }, + }, + new_text: "Some(2)".to_string(), + })), + additional_text_edits: Some(vec![lsp::TextEdit { + range: lsp::Range { + start: lsp::Position { + line: 0, + character: 20, + }, + end: lsp::Position { + line: 0, + character: 22, + }, + }, + new_text: "".to_string(), + }]), + ..Default::default() + }; + + let closure_completion_item = completion_item.clone(); + let mut request = cx.set_request_handler::(move |_, _, _| { + let task_completion_item = closure_completion_item.clone(); + async move { + Ok(Some(lsp::CompletionResponse::Array(vec![ + task_completion_item, + ]))) + } + }); + + request.next().await; + + cx.condition(|editor, _| editor.context_menu_visible()) + .await; + let apply_additional_edits = cx.update_editor(|editor, window, cx| { + editor + .confirm_completion(&ConfirmCompletion::default(), window, cx) + .unwrap() + }); + cx.assert_editor_state("fn main() { let a = 2.Some(2)ˇ; }"); + + cx.set_request_handler::(move |_, _, _| { + let task_completion_item = completion_item.clone(); + async move { Ok(task_completion_item) } + }) + .next() + .await + .unwrap(); + apply_additional_edits.await.unwrap(); + cx.assert_editor_state("fn main() { let a = Some(2)ˇ; }"); +} + +async fn check_completion_additional_edits( + initial_state: &str, + keystroke: &str, + completion_item: lsp::CompletionItem, + state_after_primary_edit: &str, + state_after_additional_edits: &str, + cx: &mut TestAppContext, +) { + init_test(cx, |_| {}); + + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + completion_provider: Some(lsp::CompletionOptions { + trigger_characters: Some(vec![".".to_string()]), + resolve_provider: Some(true), + ..Default::default() + }), + ..Default::default() + }, + cx, + ) + .await; + + cx.set_state(initial_state); + + let closure_completion_item = completion_item.clone(); + let mut request = cx.set_request_handler::(move |_, _, _| { + let task_completion_item = closure_completion_item.clone(); + async move { + Ok(Some(lsp::CompletionResponse::Array(vec![ + task_completion_item, + ]))) + } + }); + + cx.simulate_keystroke(keystroke); + request.next().await; + + cx.condition(|editor, _| editor.context_menu_visible()) + .await; + let apply_additional_edits = cx.update_editor(|editor, window, cx| { + editor + .confirm_completion(&ConfirmCompletion::default(), window, cx) + .unwrap() + }); + cx.assert_editor_state(state_after_primary_edit); + + cx.set_request_handler::(move |_, _, _| { + let task_completion_item = completion_item.clone(); + async move { Ok(task_completion_item) } + }) + .next() + .await + .unwrap(); + apply_additional_edits.await.unwrap(); + cx.assert_editor_state(state_after_additional_edits); +} + +// rust-analyzer's ref-match completions (`&some_str`) deliver the `&` as a +// zero-width additionalTextEdit at the primary edit's start. +// Ref: https://github.com/zed-industries/zed/issues/56973 +#[gpui::test] +async fn test_completions_with_zero_width_additional_edit_at_primary_edit_start( + cx: &mut TestAppContext, +) { + check_completion_additional_edits( + // The completion must not start at (0, 0), so that this case is + // distinct from the file-start auto-import test below. + "bar(fˇ)", + "o", + lsp::CompletionItem { + label: "&foo".to_string(), + filter_text: Some("foo".to_string()), + // Replaces the typed `fo` with `foo`; the committed range is 4..7. + text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit { + range: lsp::Range::new(lsp::Position::new(0, 4), lsp::Position::new(0, 6)), + new_text: "foo".to_string(), + })), + // Zero-width insertion exactly at the committed range's start. + additional_text_edits: Some(vec![lsp::TextEdit { + range: lsp::Range::new(lsp::Position::new(0, 4), lsp::Position::new(0, 4)), + new_text: "&".to_string(), + }]), + ..Default::default() + }, + "bar(fooˇ)", + "bar(&fooˇ)", + cx, + ) + .await; +} + +// Additional edits which overlap the primary completion edit must be skipped +// while non-overlapping edits from the same completion are still applied. +// Ref: https://github.com/zed-industries/zed/pull/1871 +#[gpui::test] +async fn test_completions_skip_additional_edits_overlapping_primary_edit(cx: &mut TestAppContext) { + check_completion_additional_edits( + "fˇ\nbar", + "o", + lsp::CompletionItem { + label: "foo".to_string(), + filter_text: Some("foo".to_string()), + // Replaces the typed `fo` with `foo`; the committed range is 0..3. + text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit { + range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 2)), + new_text: "foo".to_string(), + })), + additional_text_edits: Some(vec![ + // Skip text strictly inside the committed range. + lsp::TextEdit { + range: lsp::Range::new(lsp::Position::new(0, 1), lsp::Position::new(0, 2)), + new_text: "XXX".to_string(), + }, + // Apply text which does not touch the committed range. + lsp::TextEdit { + range: lsp::Range::new(lsp::Position::new(1, 0), lsp::Position::new(1, 3)), + new_text: "baz".to_string(), + }, + ]), + ..Default::default() + }, + "fooˇ\nbar", + "fooˇ\nbaz", + cx, + ) + .await; +} + +// When both the primary completion edit and an additional edit (auto-import) +// start at the very beginning of the file, the additional edit must not be +// treated as overlapping. This payload shape matches what +// typescript-language-server actually sends for auto-imports at file start. +// Ref: https://github.com/zed-industries/zed/issues/26136 +#[gpui::test] +async fn test_completions_with_file_start_auto_import_additional_edit(cx: &mut TestAppContext) { + check_completion_additional_edits( + "ˇ", + "f", + lsp::CompletionItem { + label: "foo".to_string(), + // Replaces the typed `f` at file start; the committed range is 0..3. + text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit { + range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 1)), + new_text: "foo".to_string(), + })), + // Auto-import inserted at the very start of the file. + additional_text_edits: Some(vec![lsp::TextEdit { + range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 0)), + new_text: "bar\n".to_string(), + }]), + ..Default::default() + }, + "fooˇ", + "bar\nfooˇ", + cx, + ) + .await; +} + +#[gpui::test] +async fn test_completions_with_additional_edits_undo(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + completion_provider: Some(lsp::CompletionOptions { + trigger_characters: Some(vec![".".to_string()]), + resolve_provider: Some(true), + ..Default::default() + }), + ..Default::default() + }, + cx, + ) + .await; + + cx.set_state("fn main() { let a = 2ˇ; }"); + cx.simulate_keystroke("."); + let completion_item = lsp::CompletionItem { + label: "some".into(), + kind: Some(lsp::CompletionItemKind::SNIPPET), + detail: Some("Wrap the expression in an `Option::Some`".to_string()), + documentation: Some(lsp::Documentation::MarkupContent(lsp::MarkupContent { + kind: lsp::MarkupKind::Markdown, + value: "```rust\nSome(2)\n```".to_string(), + })), + deprecated: Some(false), + sort_text: Some("fffffff2".to_string()), + filter_text: Some("some".to_string()), + insert_text_format: Some(lsp::InsertTextFormat::SNIPPET), + text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit { + range: lsp::Range { + start: lsp::Position { + line: 0, + character: 22, + }, + end: lsp::Position { + line: 0, + character: 22, + }, + }, + new_text: "Some(2)".to_string(), + })), + additional_text_edits: Some(vec![lsp::TextEdit { + range: lsp::Range { + start: lsp::Position { + line: 0, + character: 20, + }, + end: lsp::Position { + line: 0, + character: 22, + }, + }, + new_text: "".to_string(), + }]), + ..Default::default() + }; + + let closure_completion_item = completion_item.clone(); + let mut request = cx.set_request_handler::(move |_, _, _| { + let task_completion_item = closure_completion_item.clone(); + async move { + Ok(Some(lsp::CompletionResponse::Array(vec![ + task_completion_item, + ]))) + } + }); + + request.next().await; + + cx.condition(|editor, _| editor.context_menu_visible()) + .await; + let apply_additional_edits = cx.update_editor(|editor, window, cx| { + editor + .confirm_completion(&ConfirmCompletion::default(), window, cx) + .unwrap() + }); + cx.assert_editor_state("fn main() { let a = 2.Some(2)ˇ; }"); + + cx.set_request_handler::(move |_, _, _| { + let task_completion_item = completion_item.clone(); + async move { Ok(task_completion_item) } + }) + .next() + .await + .unwrap(); + apply_additional_edits.await.unwrap(); + cx.assert_editor_state("fn main() { let a = Some(2)ˇ; }"); + + cx.update_editor(|editor, window, cx| { + editor.undo(&crate::Undo, window, cx); + }); + cx.assert_editor_state("fn main() { let a = 2.ˇ; }"); +} + +#[gpui::test] +async fn test_completions_with_additional_edits_and_multiple_cursors(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorLspTestContext::new_typescript( + lsp::ServerCapabilities { + completion_provider: Some(lsp::CompletionOptions { + resolve_provider: Some(true), + ..Default::default() + }), + ..Default::default() + }, + cx, + ) + .await; + + cx.set_state( + "import { «Fooˇ» } from './types';\n\nclass Bar {\n method(): «Fooˇ» { return new Foo(); }\n}", + ); + + cx.simulate_keystroke("F"); + cx.simulate_keystroke("o"); + + let completion_item = lsp::CompletionItem { + label: "FooBar".into(), + kind: Some(lsp::CompletionItemKind::CLASS), + text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit { + range: lsp::Range { + start: lsp::Position { + line: 3, + character: 14, + }, + end: lsp::Position { + line: 3, + character: 16, + }, + }, + new_text: "FooBar".to_string(), + })), + additional_text_edits: Some(vec![lsp::TextEdit { + range: lsp::Range { + start: lsp::Position { + line: 0, + character: 9, + }, + end: lsp::Position { + line: 0, + character: 11, + }, + }, + new_text: "FooBar".to_string(), + }]), + ..Default::default() + }; + + let closure_completion_item = completion_item.clone(); + let mut request = cx.set_request_handler::(move |_, _, _| { + let task_completion_item = closure_completion_item.clone(); + async move { + Ok(Some(lsp::CompletionResponse::Array(vec![ + task_completion_item, + ]))) + } + }); + + request.next().await; + + cx.condition(|editor, _| editor.context_menu_visible()) + .await; + let apply_additional_edits = cx.update_editor(|editor, window, cx| { + editor + .confirm_completion(&ConfirmCompletion::default(), window, cx) + .unwrap() + }); + + cx.assert_editor_state( + "import { FooBarˇ } from './types';\n\nclass Bar {\n method(): FooBarˇ { return new Foo(); }\n}", + ); + + cx.set_request_handler::(move |_, _, _| { + let task_completion_item = completion_item.clone(); + async move { Ok(task_completion_item) } + }) + .next() + .await + .unwrap(); + + apply_additional_edits.await.unwrap(); + + cx.assert_editor_state( + "import { FooBarˇ } from './types';\n\nclass Bar {\n method(): FooBarˇ { return new Foo(); }\n}", + ); +} + +#[gpui::test] +async fn test_completions_resolve_updates_labels_if_filter_text_matches(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + completion_provider: Some(lsp::CompletionOptions { + trigger_characters: Some(vec![".".to_string()]), + resolve_provider: Some(true), + ..Default::default() + }), + ..Default::default() + }, + cx, + ) + .await; + + cx.set_state("fn main() { let a = 2ˇ; }"); + cx.simulate_keystroke("."); + + let item1 = lsp::CompletionItem { + label: "method id()".to_string(), + filter_text: Some("id".to_string()), + detail: None, + documentation: None, + text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit { + range: lsp::Range::new(lsp::Position::new(0, 22), lsp::Position::new(0, 22)), + new_text: ".id".to_string(), + })), + ..lsp::CompletionItem::default() + }; + + let item2 = lsp::CompletionItem { + label: "other".to_string(), + filter_text: Some("other".to_string()), + detail: None, + documentation: None, + text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit { + range: lsp::Range::new(lsp::Position::new(0, 22), lsp::Position::new(0, 22)), + new_text: ".other".to_string(), + })), + ..lsp::CompletionItem::default() + }; + + let item1 = item1.clone(); + cx.set_request_handler::({ + let item1 = item1.clone(); + move |_, _, _| { + let item1 = item1.clone(); + let item2 = item2.clone(); + async move { Ok(Some(lsp::CompletionResponse::Array(vec![item1, item2]))) } + } + }) + .next() + .await; + + cx.condition(|editor, _| editor.context_menu_visible()) + .await; + cx.update_editor(|editor, _, _| { + let context_menu = editor.context_menu.borrow_mut(); + let context_menu = context_menu + .as_ref() + .expect("Should have the context menu deployed"); + match context_menu { + CodeContextMenu::Completions(completions_menu) => { + let completions = completions_menu.completions.borrow_mut(); + assert_eq!( + completions + .iter() + .map(|completion| &completion.label.text) + .collect::>(), + vec!["method id()", "other"] + ) + } + CodeContextMenu::CodeActions(_) => panic!("Should show the completions menu"), + } + }); + + cx.set_request_handler::({ + let item1 = item1.clone(); + move |_, item_to_resolve, _| { + let item1 = item1.clone(); + async move { + if item1 == item_to_resolve { + Ok(lsp::CompletionItem { + label: "method id()".to_string(), + filter_text: Some("id".to_string()), + detail: Some("Now resolved!".to_string()), + documentation: Some(lsp::Documentation::String("Docs".to_string())), + text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit { + range: lsp::Range::new( + lsp::Position::new(0, 22), + lsp::Position::new(0, 22), + ), + new_text: ".id".to_string(), + })), + ..lsp::CompletionItem::default() + }) + } else { + Ok(item_to_resolve) + } + } + } + }) + .next() + .await + .unwrap(); + cx.run_until_parked(); + + cx.update_editor(|editor, window, cx| { + editor.context_menu_next(&Default::default(), window, cx); + }); + cx.run_until_parked(); + + cx.update_editor(|editor, _, _| { + let context_menu = editor.context_menu.borrow_mut(); + let context_menu = context_menu + .as_ref() + .expect("Should have the context menu deployed"); + match context_menu { + CodeContextMenu::Completions(completions_menu) => { + let completions = completions_menu.completions.borrow_mut(); + assert_eq!( + completions + .iter() + .map(|completion| &completion.label.text) + .collect::>(), + vec!["method id() Now resolved!", "other"], + "Should update first completion label, but not second as the filter text did not match." + ); + } + CodeContextMenu::CodeActions(_) => panic!("Should show the completions menu"), + } + }); +} + +#[gpui::test] +async fn test_context_menus_hide_hover_popover(cx: &mut gpui::TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + hover_provider: Some(lsp::HoverProviderCapability::Simple(true)), + code_action_provider: Some(lsp::CodeActionProviderCapability::Simple(true)), + completion_provider: Some(lsp::CompletionOptions { + resolve_provider: Some(true), + ..Default::default() + }), + ..Default::default() + }, + cx, + ) + .await; + cx.set_state(indoc! {" + struct TestStruct { + field: i32 + } + + fn mainˇ() { + let unused_var = 42; + let test_struct = TestStruct { field: 42 }; + } + "}); + let symbol_range = cx.lsp_range(indoc! {" + struct TestStruct { + field: i32 + } + + «fn main»() { + let unused_var = 42; + let test_struct = TestStruct { field: 42 }; + } + "}); + let mut hover_requests = + cx.set_request_handler::(move |_, _, _| async move { + Ok(Some(lsp::Hover { + contents: lsp::HoverContents::Markup(lsp::MarkupContent { + kind: lsp::MarkupKind::Markdown, + value: "Function documentation".to_string(), + }), + range: Some(symbol_range), + })) + }); + + // Case 1: Test that code action menu hide hover popover + cx.dispatch_action(Hover); + hover_requests.next().await; + cx.condition(|editor, _| editor.hover_state.visible()).await; + let mut code_action_requests = cx.set_request_handler::( + move |_, _, _| async move { + Ok(Some(vec![lsp::CodeActionOrCommand::CodeAction( + lsp::CodeAction { + title: "Remove unused variable".to_string(), + kind: Some(CodeActionKind::QUICKFIX), + edit: Some(lsp::WorkspaceEdit { + changes: Some( + [( + lsp::Uri::from_file_path(path!("/file.rs")).unwrap(), + vec![lsp::TextEdit { + range: lsp::Range::new( + lsp::Position::new(5, 4), + lsp::Position::new(5, 27), + ), + new_text: "".to_string(), + }], + )] + .into_iter() + .collect(), + ), + ..Default::default() + }), + ..Default::default() + }, + )])) + }, + ); + cx.update_editor(|editor, window, cx| { + editor.toggle_code_actions( + &ToggleCodeActions { + deployed_from: None, + quick_launch: false, + }, + window, + cx, + ); + }); + code_action_requests.next().await; + cx.run_until_parked(); + cx.condition(|editor, _| editor.context_menu_visible()) + .await; + cx.update_editor(|editor, _, _| { + assert!( + !editor.hover_state.visible(), + "Hover popover should be hidden when code action menu is shown" + ); + // Hide code actions + editor.context_menu.take(); + }); + + // Case 2: Test that code completions hide hover popover + cx.dispatch_action(Hover); + hover_requests.next().await; + cx.condition(|editor, _| editor.hover_state.visible()).await; + let counter = Arc::new(AtomicUsize::new(0)); + let mut completion_requests = + cx.set_request_handler::(move |_, _, _| { + let counter = counter.clone(); + async move { + counter.fetch_add(1, atomic::Ordering::Release); + Ok(Some(lsp::CompletionResponse::Array(vec![ + lsp::CompletionItem { + label: "main".into(), + kind: Some(lsp::CompletionItemKind::FUNCTION), + detail: Some("() -> ()".to_string()), + ..Default::default() + }, + lsp::CompletionItem { + label: "TestStruct".into(), + kind: Some(lsp::CompletionItemKind::STRUCT), + detail: Some("struct TestStruct".to_string()), + ..Default::default() + }, + ]))) + } + }); + cx.update_editor(|editor, window, cx| { + editor.show_completions(&ShowCompletions, window, cx); + }); + completion_requests.next().await; + cx.condition(|editor, _| editor.context_menu_visible()) + .await; + cx.update_editor(|editor, _, _| { + assert!( + !editor.hover_state.visible(), + "Hover popover should be hidden when completion menu is shown" + ); + }); +} + +#[gpui::test] +async fn test_completions_resolve_happens_once(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + completion_provider: Some(lsp::CompletionOptions { + trigger_characters: Some(vec![".".to_string()]), + resolve_provider: Some(true), + ..Default::default() + }), + ..Default::default() + }, + cx, + ) + .await; + + cx.set_state("fn main() { let a = 2ˇ; }"); + cx.simulate_keystroke("."); + + let unresolved_item_1 = lsp::CompletionItem { + label: "id".to_string(), + filter_text: Some("id".to_string()), + detail: None, + documentation: None, + text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit { + range: lsp::Range::new(lsp::Position::new(0, 22), lsp::Position::new(0, 22)), + new_text: ".id".to_string(), + })), + ..lsp::CompletionItem::default() + }; + let resolved_item_1 = lsp::CompletionItem { + additional_text_edits: Some(vec![lsp::TextEdit { + range: lsp::Range::new(lsp::Position::new(0, 20), lsp::Position::new(0, 22)), + new_text: "!!".to_string(), + }]), + ..unresolved_item_1.clone() + }; + let unresolved_item_2 = lsp::CompletionItem { + label: "other".to_string(), + filter_text: Some("other".to_string()), + detail: None, + documentation: None, + text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit { + range: lsp::Range::new(lsp::Position::new(0, 22), lsp::Position::new(0, 22)), + new_text: ".other".to_string(), + })), + ..lsp::CompletionItem::default() + }; + let resolved_item_2 = lsp::CompletionItem { + additional_text_edits: Some(vec![lsp::TextEdit { + range: lsp::Range::new(lsp::Position::new(0, 20), lsp::Position::new(0, 22)), + new_text: "??".to_string(), + }]), + ..unresolved_item_2.clone() + }; + + let resolve_requests_1 = Arc::new(AtomicUsize::new(0)); + let resolve_requests_2 = Arc::new(AtomicUsize::new(0)); + cx.lsp + .server + .on_request::({ + let unresolved_item_1 = unresolved_item_1.clone(); + let resolved_item_1 = resolved_item_1.clone(); + let unresolved_item_2 = unresolved_item_2.clone(); + let resolved_item_2 = resolved_item_2.clone(); + let resolve_requests_1 = resolve_requests_1.clone(); + let resolve_requests_2 = resolve_requests_2.clone(); + move |unresolved_request, _| { + let unresolved_item_1 = unresolved_item_1.clone(); + let resolved_item_1 = resolved_item_1.clone(); + let unresolved_item_2 = unresolved_item_2.clone(); + let resolved_item_2 = resolved_item_2.clone(); + let resolve_requests_1 = resolve_requests_1.clone(); + let resolve_requests_2 = resolve_requests_2.clone(); + async move { + if unresolved_request == unresolved_item_1 { + resolve_requests_1.fetch_add(1, atomic::Ordering::Release); + Ok(resolved_item_1.clone()) + } else if unresolved_request == unresolved_item_2 { + resolve_requests_2.fetch_add(1, atomic::Ordering::Release); + Ok(resolved_item_2.clone()) + } else { + panic!("Unexpected completion item {unresolved_request:?}") + } + } + } + }) + .detach(); + + cx.set_request_handler::(move |_, _, _| { + let unresolved_item_1 = unresolved_item_1.clone(); + let unresolved_item_2 = unresolved_item_2.clone(); + async move { + Ok(Some(lsp::CompletionResponse::Array(vec![ + unresolved_item_1, + unresolved_item_2, + ]))) + } + }) + .next() + .await; + + cx.condition(|editor, _| editor.context_menu_visible()) + .await; + cx.update_editor(|editor, _, _| { + let context_menu = editor.context_menu.borrow_mut(); + let context_menu = context_menu + .as_ref() + .expect("Should have the context menu deployed"); + match context_menu { + CodeContextMenu::Completions(completions_menu) => { + let completions = completions_menu.completions.borrow_mut(); + assert_eq!( + completions + .iter() + .map(|completion| &completion.label.text) + .collect::>(), + vec!["id", "other"] + ) + } + CodeContextMenu::CodeActions(_) => panic!("Should show the completions menu"), + } + }); + cx.run_until_parked(); + + cx.update_editor(|editor, window, cx| { + editor.context_menu_next(&ContextMenuNext, window, cx); + }); + cx.run_until_parked(); + cx.update_editor(|editor, window, cx| { + editor.context_menu_prev(&ContextMenuPrevious, window, cx); + }); + cx.run_until_parked(); + cx.update_editor(|editor, window, cx| { + editor.context_menu_next(&ContextMenuNext, window, cx); + }); + cx.run_until_parked(); + cx.update_editor(|editor, window, cx| { + editor + .compose_completion(&ComposeCompletion::default(), window, cx) + .expect("No task returned") + }) + .await + .expect("Completion failed"); + cx.run_until_parked(); + + cx.update_editor(|editor, _, cx| { + assert_eq!( + resolve_requests_1.load(atomic::Ordering::Acquire), + 1, + "Should always resolve once despite multiple selections" + ); + assert_eq!( + resolve_requests_2.load(atomic::Ordering::Acquire), + 1, + "Should always resolve once after multiple selections and applying the completion" + ); + assert_eq!( + editor.text(cx), + "fn main() { let a = ??.other; }", + "Should use resolved data when applying the completion" + ); + }); +} + +#[gpui::test] +async fn test_completions_default_resolve_data_handling(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let item_0 = lsp::CompletionItem { + label: "abs".into(), + insert_text: Some("abs".into()), + data: Some(json!({ "very": "special"})), + insert_text_mode: Some(lsp::InsertTextMode::ADJUST_INDENTATION), + text_edit: Some(lsp::CompletionTextEdit::InsertAndReplace( + lsp::InsertReplaceEdit { + new_text: "abs".to_string(), + insert: lsp::Range::default(), + replace: lsp::Range::default(), + }, + )), + ..lsp::CompletionItem::default() + }; + let items = iter::once(item_0.clone()) + .chain((11..51).map(|i| lsp::CompletionItem { + label: format!("item_{}", i), + insert_text: Some(format!("item_{}", i)), + insert_text_format: Some(lsp::InsertTextFormat::PLAIN_TEXT), + ..lsp::CompletionItem::default() + })) + .collect::>(); + + let default_commit_characters = vec!["?".to_string()]; + let default_data = json!({ "default": "data"}); + let default_insert_text_format = lsp::InsertTextFormat::SNIPPET; + let default_insert_text_mode = lsp::InsertTextMode::AS_IS; + let default_edit_range = lsp::Range { + start: lsp::Position { + line: 0, + character: 5, + }, + end: lsp::Position { + line: 0, + character: 5, + }, + }; + + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + completion_provider: Some(lsp::CompletionOptions { + trigger_characters: Some(vec![".".to_string()]), + resolve_provider: Some(true), + ..Default::default() + }), + ..Default::default() + }, + cx, + ) + .await; + + cx.set_state("fn main() { let a = 2ˇ; }"); + cx.simulate_keystroke("."); + + let completion_data = default_data.clone(); + let completion_characters = default_commit_characters.clone(); + let completion_items = items.clone(); + cx.set_request_handler::(move |_, _, _| { + let default_data = completion_data.clone(); + let default_commit_characters = completion_characters.clone(); + let items = completion_items.clone(); + async move { + Ok(Some(lsp::CompletionResponse::List(lsp::CompletionList { + items, + item_defaults: Some(lsp::CompletionListItemDefaults { + data: Some(default_data.clone()), + commit_characters: Some(default_commit_characters.clone()), + edit_range: Some(lsp::CompletionListItemDefaultsEditRange::Range( + default_edit_range, + )), + insert_text_format: Some(default_insert_text_format), + insert_text_mode: Some(default_insert_text_mode), + }), + ..lsp::CompletionList::default() + }))) + } + }) + .next() + .await; + + let resolved_items = Arc::new(Mutex::new(Vec::new())); + cx.lsp + .server + .on_request::({ + let closure_resolved_items = resolved_items.clone(); + move |item_to_resolve, _| { + let closure_resolved_items = closure_resolved_items.clone(); + async move { + closure_resolved_items.lock().push(item_to_resolve.clone()); + Ok(item_to_resolve) + } + } + }) + .detach(); + + cx.condition(|editor, _| editor.context_menu_visible()) + .await; + cx.run_until_parked(); + cx.update_editor(|editor, _, _| { + let menu = editor.context_menu.borrow_mut(); + match menu.as_ref().expect("should have the completions menu") { + CodeContextMenu::Completions(completions_menu) => { + assert_eq!( + completions_menu + .entries + .borrow() + .iter() + .filter_map(|entry| entry.as_match().map(|m| m.string.clone())) + .collect::>(), + items + .iter() + .map(|completion| completion.label.clone()) + .collect::>() + ); + } + CodeContextMenu::CodeActions(_) => panic!("Expected to have the completions menu"), + } + }); + // Approximate initial displayed interval is 0..12. With extra item padding of 4 this is 0..16 + // with 4 from the end. + assert_eq!( + *resolved_items.lock(), + [&items[0..16], &items[items.len() - 4..items.len()]] + .concat() + .iter() + .cloned() + .map(|mut item| { + if item.data.is_none() { + item.data = Some(default_data.clone()); + } + item + }) + .collect::>(), + "Items sent for resolve should be unchanged modulo resolve `data` filled with default if missing" + ); + resolved_items.lock().clear(); + + cx.update_editor(|editor, window, cx| { + editor.context_menu_prev(&ContextMenuPrevious, window, cx); + }); + cx.run_until_parked(); + // Completions that have already been resolved are skipped. + assert_eq!( + *resolved_items.lock(), + items[items.len() - 17..items.len() - 4] + .iter() + .cloned() + .map(|mut item| { + if item.data.is_none() { + item.data = Some(default_data.clone()); + } + item + }) + .collect::>() + ); + resolved_items.lock().clear(); +} + +#[gpui::test] +async fn test_completions_in_languages_with_extra_word_characters(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorLspTestContext::new( + Language::new( + LanguageConfig { + matcher: (LanguageMatcher { + path_suffixes: vec!["jsx".into()], + ..Default::default() + }) + .into(), + overrides: [( + "element".into(), + LanguageConfigOverride { + completion_query_characters: Override::Set(['-'].into_iter().collect()), + ..Default::default() + }, + )] + .into_iter() + .collect(), + ..Default::default() + }, + Some(tree_sitter_typescript::LANGUAGE_TSX.into()), + ) + .with_override_query("(jsx_self_closing_element) @element") + .unwrap(), + lsp::ServerCapabilities { + completion_provider: Some(lsp::CompletionOptions { + trigger_characters: Some(vec![":".to_string()]), + ..Default::default() + }), + ..Default::default() + }, + cx, + ) + .await; + + cx.lsp + .set_request_handler::(move |_, _| async move { + Ok(Some(lsp::CompletionResponse::Array(vec![ + lsp::CompletionItem { + label: "bg-blue".into(), + ..Default::default() + }, + lsp::CompletionItem { + label: "bg-red".into(), + ..Default::default() + }, + lsp::CompletionItem { + label: "bg-yellow".into(), + ..Default::default() + }, + ]))) + }); + + cx.set_state(r#"

"#); + + // Trigger completion when typing a dash, because the dash is an extra + // word character in the 'element' scope, which contains the cursor. + cx.simulate_keystroke("-"); + cx.executor().run_until_parked(); + cx.update_editor(|editor, _, _| { + if let Some(CodeContextMenu::Completions(menu)) = editor.context_menu.borrow_mut().as_ref() + { + assert_eq!( + completion_menu_entries(menu), + &["bg-blue", "bg-red", "bg-yellow"] + ); + } else { + panic!("expected completion menu to be open"); + } + }); + + cx.simulate_keystroke("l"); + cx.executor().run_until_parked(); + cx.update_editor(|editor, _, _| { + if let Some(CodeContextMenu::Completions(menu)) = editor.context_menu.borrow_mut().as_ref() + { + assert_eq!(completion_menu_entries(menu), &["bg-blue", "bg-yellow"]); + } else { + panic!("expected completion menu to be open"); + } + }); + + // When filtering completions, consider the character after the '-' to + // be the start of a subword. + cx.set_state(r#"

"#); + cx.simulate_keystroke("l"); + cx.executor().run_until_parked(); + cx.update_editor(|editor, _, _| { + if let Some(CodeContextMenu::Completions(menu)) = editor.context_menu.borrow_mut().as_ref() + { + assert_eq!(completion_menu_entries(menu), &["bg-yellow"]); + } else { + panic!("expected completion menu to be open"); + } + }); +} + +fn completion_menu_entries(menu: &CompletionsMenu) -> Vec { + let entries = menu.entries.borrow(); + entries + .iter() + .filter_map(|entry| entry.as_match().map(|m| m.string.clone())) + .collect() +} + +#[gpui::test] +async fn test_document_format_with_prettier(cx: &mut TestAppContext) { + init_test(cx, |settings| { + settings.defaults.formatter = Some(FormatterList::Single(Formatter::Prettier)) + }); + + let fs = FakeFs::new(cx.executor()); + fs.insert_file(path!("/file.ts"), Default::default()).await; + + let project = Project::test(fs, [path!("/file.ts").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + + language_registry.add(Arc::new(Language::new( + LanguageConfig { + name: "TypeScript".into(), + matcher: (LanguageMatcher { + path_suffixes: vec!["ts".to_string()], + ..Default::default() + }) + .into(), + ..Default::default() + }, + Some(tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into()), + ))); + update_test_language_settings(cx, &|settings| { + settings.defaults.prettier.get_or_insert_default().allowed = Some(true); + }); + + let test_plugin = "test_plugin"; + let _ = language_registry.register_fake_lsp( + "TypeScript", + FakeLspAdapter { + prettier_plugins: vec![test_plugin], + ..Default::default() + }, + ); + + let prettier_format_suffix = project::TEST_PRETTIER_FORMAT_SUFFIX; + let buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/file.ts"), cx) + }) + .await + .unwrap(); + + let buffer_text = "one\ntwo\nthree\n"; + let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + let (editor, cx) = cx.add_window_view(|window, cx| build_editor(buffer, window, cx)); + editor.update_in(cx, |editor, window, cx| { + editor.set_text(buffer_text, window, cx) + }); + + editor + .update_in(cx, |editor, window, cx| { + editor.perform_format( + project.clone(), + FormatTrigger::Manual, + FormatTarget::Buffers(editor.buffer().read(cx).all_buffers()), + window, + cx, + ) + }) + .unwrap() + .await; + assert_eq!( + editor.update(cx, |editor, cx| editor.text(cx)), + buffer_text.to_string() + prettier_format_suffix, + "Test prettier formatting was not applied to the original buffer text", + ); + + update_test_language_settings(cx, &|settings| { + settings.defaults.formatter = Some(FormatterList::default()) + }); + let format = editor.update_in(cx, |editor, window, cx| { + editor.perform_format( + project.clone(), + FormatTrigger::Manual, + FormatTarget::Buffers(editor.buffer().read(cx).all_buffers()), + window, + cx, + ) + }); + format.await.unwrap(); + assert_eq!( + editor.update(cx, |editor, cx| editor.text(cx)), + buffer_text.to_string() + prettier_format_suffix + "\n" + prettier_format_suffix, + "Autoformatting (via test prettier) was not applied to the original buffer text", + ); +} + +#[gpui::test] +async fn test_document_format_with_prettier_explicit_language(cx: &mut TestAppContext) { + init_test(cx, |settings| { + settings.defaults.formatter = Some(FormatterList::Single(Formatter::Prettier)) + }); + + let fs = FakeFs::new(cx.executor()); + fs.insert_file(path!("/file.settings"), Default::default()) + .await; + + let project = Project::test(fs, [path!("/file.settings").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + + let ts_lang = Arc::new(Language::new( + LanguageConfig { + name: "TypeScript".into(), + matcher: (LanguageMatcher { + path_suffixes: vec!["ts".to_string()], + ..LanguageMatcher::default() + }) + .into(), + prettier_parser_name: Some("typescript".to_string()), + ..LanguageConfig::default() + }, + Some(tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into()), + )); + + language_registry.add(ts_lang.clone()); + + update_test_language_settings(cx, &|settings| { + settings.defaults.prettier.get_or_insert_default().allowed = Some(true); + }); + + let test_plugin = "test_plugin"; + let _ = language_registry.register_fake_lsp( + "TypeScript", + FakeLspAdapter { + prettier_plugins: vec![test_plugin], + ..Default::default() + }, + ); + + let prettier_format_suffix = project::TEST_PRETTIER_FORMAT_SUFFIX; + let buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/file.settings"), cx) + }) + .await + .unwrap(); + + project.update(cx, |project, cx| { + project.set_language_for_buffer(&buffer, ts_lang, cx) + }); + + let buffer_text = "one\ntwo\nthree\n"; + let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + let (editor, cx) = cx.add_window_view(|window, cx| build_editor(buffer, window, cx)); + editor.update_in(cx, |editor, window, cx| { + editor.set_text(buffer_text, window, cx) + }); + + editor + .update_in(cx, |editor, window, cx| { + editor.perform_format( + project.clone(), + FormatTrigger::Manual, + FormatTarget::Buffers(editor.buffer().read(cx).all_buffers()), + window, + cx, + ) + }) + .unwrap() + .await; + assert_eq!( + editor.update(cx, |editor, cx| editor.text(cx)), + buffer_text.to_string() + prettier_format_suffix + "\ntypescript", + "Test prettier formatting was not applied to the original buffer text", + ); + + update_test_language_settings(cx, &|settings| { + settings.defaults.formatter = Some(FormatterList::default()) + }); + let format = editor.update_in(cx, |editor, window, cx| { + editor.perform_format( + project.clone(), + FormatTrigger::Manual, + FormatTarget::Buffers(editor.buffer().read(cx).all_buffers()), + window, + cx, + ) + }); + format.await.unwrap(); + + assert_eq!( + editor.update(cx, |editor, cx| editor.text(cx)), + buffer_text.to_string() + + prettier_format_suffix + + "\ntypescript\n" + + prettier_format_suffix + + "\ntypescript", + "Autoformatting (via test prettier) was not applied to the original buffer text", + ); +} + +#[gpui::test] +async fn test_range_format_with_prettier(cx: &mut TestAppContext) { + init_test(cx, |settings| { + settings.defaults.formatter = Some(FormatterList::Single(Formatter::Prettier)) + }); + + let fs = FakeFs::new(cx.executor()); + fs.insert_file(path!("/file.ts"), Default::default()).await; + + let project = Project::test(fs, [path!("/file.ts").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + + language_registry.add(Arc::new(Language::new( + LanguageConfig { + name: "TypeScript".into(), + matcher: (LanguageMatcher { + path_suffixes: vec!["ts".to_string()], + ..Default::default() + }) + .into(), + ..Default::default() + }, + Some(tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into()), + ))); + update_test_language_settings(cx, &|settings| { + settings.defaults.prettier.get_or_insert_default().allowed = Some(true); + }); + + let test_plugin = "test_plugin"; + let _ = language_registry.register_fake_lsp( + "TypeScript", + FakeLspAdapter { + prettier_plugins: vec![test_plugin], + ..Default::default() + }, + ); + + let prettier_range_format_suffix = project::TEST_PRETTIER_RANGE_FORMAT_SUFFIX; + let buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/file.ts"), cx) + }) + .await + .unwrap(); + + let buffer_text = "one\ntwo\nthree\nfour\nfive\n"; + let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + let (editor, cx) = cx.add_window_view(|window, cx| { + build_editor_with_project(project.clone(), buffer, window, cx) + }); + editor.update_in(cx, |editor, window, cx| { + editor.set_text(buffer_text, window, cx) + }); + + cx.executor().run_until_parked(); + + editor.update_in(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::default(), window, cx, |s| { + s.select_ranges([Point::new(1, 0)..Point::new(3, 0)]) + }); + }); + + let format = editor + .update_in(cx, |editor, window, cx| { + editor.format_selections(&FormatSelections, window, cx) + }) + .unwrap(); + format.await.unwrap(); + + assert_eq!( + editor.update(cx, |editor, cx| editor.text(cx)), + format!("one\ntwo{prettier_range_format_suffix}\nthree\nfour\nfive\n"), + "Range formatting (via test prettier) was not applied to the buffer text", + ); +} + +#[gpui::test] +async fn test_range_format_with_prettier_explicit_language(cx: &mut TestAppContext) { + init_test(cx, |settings| { + settings.defaults.formatter = Some(FormatterList::Single(Formatter::Prettier)) + }); + + let fs = FakeFs::new(cx.executor()); + fs.insert_file(path!("/file.settings"), Default::default()) + .await; + + let project = Project::test(fs, [path!("/file.settings").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + + let ts_lang = Arc::new(Language::new( + LanguageConfig { + name: "TypeScript".into(), + matcher: (LanguageMatcher { + path_suffixes: vec!["ts".to_string()], + ..LanguageMatcher::default() + }) + .into(), + prettier_parser_name: Some("typescript".to_string()), + ..LanguageConfig::default() + }, + Some(tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into()), + )); + + language_registry.add(ts_lang.clone()); + + update_test_language_settings(cx, &|settings| { + settings.defaults.prettier.get_or_insert_default().allowed = Some(true); + }); + + let test_plugin = "test_plugin"; + let _ = language_registry.register_fake_lsp( + "TypeScript", + FakeLspAdapter { + prettier_plugins: vec![test_plugin], + ..Default::default() + }, + ); + + let prettier_range_format_suffix = project::TEST_PRETTIER_RANGE_FORMAT_SUFFIX; + let buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/file.settings"), cx) + }) + .await + .unwrap(); + + project.update(cx, |project, cx| { + project.set_language_for_buffer(&buffer, ts_lang, cx) + }); + + let buffer_text = "one\ntwo\nthree\nfour\nfive\n"; + let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + let (editor, cx) = cx.add_window_view(|window, cx| { + build_editor_with_project(project.clone(), buffer, window, cx) + }); + editor.update_in(cx, |editor, window, cx| { + editor.set_text(buffer_text, window, cx) + }); + + cx.executor().run_until_parked(); + + editor.update_in(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::default(), window, cx, |s| { + s.select_ranges([Point::new(1, 0)..Point::new(3, 0)]) + }); + }); + + let format = editor + .update_in(cx, |editor, window, cx| { + editor.format_selections(&FormatSelections, window, cx) + }) + .unwrap(); + format.await.unwrap(); + + assert_eq!( + editor.update(cx, |editor, cx| editor.text(cx)), + format!("one\ntwo{prettier_range_format_suffix}\ntypescript\nthree\nfour\nfive\n"), + "Range formatting (via test prettier) was not applied with explicit language", + ); +} + +#[gpui::test] +async fn test_addition_reverts(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorLspTestContext::new_rust(lsp::ServerCapabilities::default(), cx).await; + let base_text = indoc! {r#" + struct Row; + struct Row1; + struct Row2; + + struct Row4; + struct Row5; + struct Row6; + + struct Row8; + struct Row9; + struct Row10;"#}; + + // When addition hunks are not adjacent to carets, no hunk revert is performed + assert_hunk_revert( + indoc! {r#"struct Row; + struct Row1; + struct Row1.1; + struct Row1.2; + struct Row2;ˇ + + struct Row4; + struct Row5; + struct Row6; + + struct Row8; + ˇstruct Row9; + struct Row9.1; + struct Row9.2; + struct Row9.3; + struct Row10;"#}, + vec![DiffHunkStatusKind::Added, DiffHunkStatusKind::Added], + indoc! {r#"struct Row; + struct Row1; + struct Row1.1; + struct Row1.2; + struct Row2;ˇ + + struct Row4; + struct Row5; + struct Row6; + + struct Row8; + ˇstruct Row9; + struct Row9.1; + struct Row9.2; + struct Row9.3; + struct Row10;"#}, + base_text, + &mut cx, + ); + // Same for selections + assert_hunk_revert( + indoc! {r#"struct Row; + struct Row1; + struct Row2; + struct Row2.1; + struct Row2.2; + «ˇ + struct Row4; + struct» Row5; + «struct Row6; + ˇ» + struct Row9.1; + struct Row9.2; + struct Row9.3; + struct Row8; + struct Row9; + struct Row10;"#}, + vec![DiffHunkStatusKind::Added, DiffHunkStatusKind::Added], + indoc! {r#"struct Row; + struct Row1; + struct Row2; + struct Row2.1; + struct Row2.2; + «ˇ + struct Row4; + struct» Row5; + «struct Row6; + ˇ» + struct Row9.1; + struct Row9.2; + struct Row9.3; + struct Row8; + struct Row9; + struct Row10;"#}, + base_text, + &mut cx, + ); + + // When carets and selections intersect the addition hunks, those are reverted. + // Adjacent carets got merged. + assert_hunk_revert( + indoc! {r#"struct Row; + ˇ// something on the top + struct Row1; + struct Row2; + struct Roˇw3.1; + struct Row2.2; + struct Row2.3;ˇ + + struct Row4; + struct ˇRow5.1; + struct Row5.2; + struct «Rowˇ»5.3; + struct Row5; + struct Row6; + ˇ + struct Row9.1; + struct «Rowˇ»9.2; + struct «ˇRow»9.3; + struct Row8; + struct Row9; + «ˇ// something on bottom» + struct Row10;"#}, + vec![ + DiffHunkStatusKind::Added, + DiffHunkStatusKind::Added, + DiffHunkStatusKind::Added, + DiffHunkStatusKind::Added, + DiffHunkStatusKind::Added, + ], + indoc! {r#"struct Row; + ˇstruct Row1; + struct Row2; + ˇ + struct Row4; + ˇstruct Row5; + struct Row6; + ˇ + ˇstruct Row8; + struct Row9; + ˇstruct Row10;"#}, + base_text, + &mut cx, + ); +} + +#[gpui::test] +async fn test_modification_reverts(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorLspTestContext::new_rust(lsp::ServerCapabilities::default(), cx).await; + let base_text = indoc! {r#" + struct Row; + struct Row1; + struct Row2; + + struct Row4; + struct Row5; + struct Row6; + + struct Row8; + struct Row9; + struct Row10;"#}; + + // Modification hunks behave the same as the addition ones. + assert_hunk_revert( + indoc! {r#"struct Row; + struct Row1; + struct Row33; + ˇ + struct Row4; + struct Row5; + struct Row6; + ˇ + struct Row99; + struct Row9; + struct Row10;"#}, + vec![DiffHunkStatusKind::Modified, DiffHunkStatusKind::Modified], + indoc! {r#"struct Row; + struct Row1; + struct Row33; + ˇ + struct Row4; + struct Row5; + struct Row6; + ˇ + struct Row99; + struct Row9; + struct Row10;"#}, + base_text, + &mut cx, + ); + assert_hunk_revert( + indoc! {r#"struct Row; + struct Row1; + struct Row33; + «ˇ + struct Row4; + struct» Row5; + «struct Row6; + ˇ» + struct Row99; + struct Row9; + struct Row10;"#}, + vec![DiffHunkStatusKind::Modified, DiffHunkStatusKind::Modified], + indoc! {r#"struct Row; + struct Row1; + struct Row33; + «ˇ + struct Row4; + struct» Row5; + «struct Row6; + ˇ» + struct Row99; + struct Row9; + struct Row10;"#}, + base_text, + &mut cx, + ); + + assert_hunk_revert( + indoc! {r#"ˇstruct Row1.1; + struct Row1; + «ˇstr»uct Row22; + + struct ˇRow44; + struct Row5; + struct «Rˇ»ow66;ˇ + + «struˇ»ct Row88; + struct Row9; + struct Row1011;ˇ"#}, + vec![ + DiffHunkStatusKind::Modified, + DiffHunkStatusKind::Modified, + DiffHunkStatusKind::Modified, + DiffHunkStatusKind::Modified, + DiffHunkStatusKind::Modified, + DiffHunkStatusKind::Modified, + ], + indoc! {r#"struct Row; + ˇstruct Row1; + struct Row2; + ˇ + struct Row4; + ˇstruct Row5; + struct Row6; + ˇ + struct Row8; + ˇstruct Row9; + struct Row10;ˇ"#}, + base_text, + &mut cx, + ); +} + +#[gpui::test] +async fn test_deleting_over_diff_hunk(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorLspTestContext::new_rust(lsp::ServerCapabilities::default(), cx).await; + let base_text = indoc! {r#" + one + + two + three + "#}; + + cx.set_head_text(base_text); + cx.set_state("\nˇ\n"); + cx.executor().run_until_parked(); + cx.update_editor(|editor, _window, cx| { + editor.expand_selected_diff_hunks(cx); + }); + cx.executor().run_until_parked(); + cx.update_editor(|editor, window, cx| { + editor.backspace(&Default::default(), window, cx); + }); + cx.run_until_parked(); + cx.assert_state_with_diff( + indoc! {r#" + + - two + - threeˇ + + + "#} + .to_string(), + ); +} + +#[gpui::test] +async fn test_deletion_reverts(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorLspTestContext::new_rust(lsp::ServerCapabilities::default(), cx).await; + let base_text = indoc! {r#"struct Row; +struct Row1; +struct Row2; + +struct Row4; +struct Row5; +struct Row6; + +struct Row8; +struct Row9; +struct Row10;"#}; + + // Deletion hunks trigger with carets on adjacent rows, so carets and selections have to stay farther to avoid the revert + assert_hunk_revert( + indoc! {r#"struct Row; + struct Row2; + + ˇstruct Row4; + struct Row5; + struct Row6; + ˇ + struct Row8; + struct Row10;"#}, + vec![DiffHunkStatusKind::Deleted, DiffHunkStatusKind::Deleted], + indoc! {r#"struct Row; + struct Row2; + + ˇstruct Row4; + struct Row5; + struct Row6; + ˇ + struct Row8; + struct Row10;"#}, + base_text, + &mut cx, + ); + assert_hunk_revert( + indoc! {r#"struct Row; + struct Row2; + + «ˇstruct Row4; + struct» Row5; + «struct Row6; + ˇ» + struct Row8; + struct Row10;"#}, + vec![DiffHunkStatusKind::Deleted, DiffHunkStatusKind::Deleted], + indoc! {r#"struct Row; + struct Row2; + + «ˇstruct Row4; + struct» Row5; + «struct Row6; + ˇ» + struct Row8; + struct Row10;"#}, + base_text, + &mut cx, + ); + + // Deletion hunks are ephemeral, so it's impossible to place the caret into them — Zed triggers reverts for lines, adjacent to carets and selections. + assert_hunk_revert( + indoc! {r#"struct Row; + ˇstruct Row2; + + struct Row4; + struct Row5; + struct Row6; + + struct Row8;ˇ + struct Row10;"#}, + vec![DiffHunkStatusKind::Deleted, DiffHunkStatusKind::Deleted], + indoc! {r#"struct Row; + struct Row1; + ˇstruct Row2; + + struct Row4; + struct Row5; + struct Row6; + + struct Row8;ˇ + struct Row9; + struct Row10;"#}, + base_text, + &mut cx, + ); + assert_hunk_revert( + indoc! {r#"struct Row; + struct Row2«ˇ; + struct Row4; + struct» Row5; + «struct Row6; + + struct Row8;ˇ» + struct Row10;"#}, + vec![ + DiffHunkStatusKind::Deleted, + DiffHunkStatusKind::Deleted, + DiffHunkStatusKind::Deleted, + ], + indoc! {r#"struct Row; + struct Row1; + struct Row2«ˇ; + + struct Row4; + struct» Row5; + «struct Row6; + + struct Row8;ˇ» + struct Row9; + struct Row10;"#}, + base_text, + &mut cx, + ); +} + +#[gpui::test] +async fn test_multibuffer_reverts(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let base_text_1 = "aaaa\nbbbb\ncccc\ndddd\neeee\nffff\ngggg\nhhhh\niiii\njjjj"; + let base_text_2 = "llll\nmmmm\nnnnn\noooo\npppp\nqqqq\nrrrr\nssss\ntttt\nuuuu"; + let base_text_3 = + "vvvv\nwwww\nxxxx\nyyyy\nzzzz\n{{{{\n||||\n}}}}\n~~~~\n\u{7f}\u{7f}\u{7f}\u{7f}"; + + let text_1 = edit_first_char_of_every_line(base_text_1); + let text_2 = edit_first_char_of_every_line(base_text_2); + let text_3 = edit_first_char_of_every_line(base_text_3); + + let buffer_1 = cx.new(|cx| Buffer::local(text_1.clone(), cx)); + let buffer_2 = cx.new(|cx| Buffer::local(text_2.clone(), cx)); + let buffer_3 = cx.new(|cx| Buffer::local(text_3.clone(), cx)); + + let multibuffer = cx.new(|cx| { + let mut multibuffer = MultiBuffer::new(ReadWrite); + multibuffer.set_excerpts_for_path( + PathKey::sorted(0), + buffer_1.clone(), + [ + Point::new(0, 0)..Point::new(2, 0), + Point::new(5, 0)..Point::new(6, 0), + Point::new(9, 0)..Point::new(9, 4), + ], + 0, + cx, + ); + multibuffer.set_excerpts_for_path( + PathKey::sorted(1), + buffer_2.clone(), + [ + Point::new(0, 0)..Point::new(2, 0), + Point::new(5, 0)..Point::new(6, 0), + Point::new(9, 0)..Point::new(9, 4), + ], + 0, + cx, + ); + multibuffer.set_excerpts_for_path( + PathKey::sorted(2), + buffer_3.clone(), + [ + Point::new(0, 0)..Point::new(2, 0), + Point::new(5, 0)..Point::new(6, 0), + Point::new(9, 0)..Point::new(9, 4), + ], + 0, + cx, + ); + multibuffer + }); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [path!("/").as_ref()], cx).await; + let (editor, cx) = cx + .add_window_view(|window, cx| build_editor_with_project(project, multibuffer, window, cx)); + editor.update_in(cx, |editor, _window, cx| { + for (buffer, diff_base) in [ + (buffer_1.clone(), base_text_1), + (buffer_2.clone(), base_text_2), + (buffer_3.clone(), base_text_3), + ] { + let diff = cx.new(|cx| { + BufferDiff::new_with_base_text(diff_base, &buffer.read(cx).text_snapshot(), cx) + }); + editor + .buffer + .update(cx, |buffer, cx| buffer.add_diff(diff, cx)); + } + }); + cx.executor().run_until_parked(); + + editor.update_in(cx, |editor, window, cx| { + assert_eq!(editor.display_text(cx), "\n\nXaaa\nXbbb\nXccc\n\nXfff\nXggg\n\nXjjj\n\n\nXlll\nXmmm\nXnnn\n\nXqqq\nXrrr\n\nXuuu\n\n\nXvvv\nXwww\nXxxx\n\nX{{{\nX|||\n\nX\u{7f}\u{7f}\u{7f}"); + editor.select_all(&SelectAll, window, cx); + editor.git_restore(&Default::default(), window, cx); + }); + cx.executor().run_until_parked(); + + // When all ranges are selected, all buffer hunks are reverted. + editor.update(cx, |editor, cx| { + assert_eq!(editor.display_text(cx), "\n\naaaa\nbbbb\ncccc\ndddd\neeee\nffff\ngggg\nhhhh\niiii\njjjj\n\n\n\n\n\n\nllll\nmmmm\nnnnn\noooo\npppp\nqqqq\nrrrr\nssss\ntttt\nuuuu\n\n\n\n\n\n\nvvvv\nwwww\nxxxx\nyyyy\nzzzz\n{{{{\n||||\n}}}}\n~~~~\n\u{7f}\u{7f}\u{7f}\u{7f}\n\n\n\n"); + }); + buffer_1.update(cx, |buffer, _| { + assert_eq!(buffer.text(), base_text_1); + }); + buffer_2.update(cx, |buffer, _| { + assert_eq!(buffer.text(), base_text_2); + }); + buffer_3.update(cx, |buffer, _| { + assert_eq!(buffer.text(), base_text_3); + }); + + editor.update_in(cx, |editor, window, cx| { + editor.undo(&Default::default(), window, cx); + }); + + editor.update_in(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_ranges(Some(Point::new(0, 0)..Point::new(5, 0))); + }); + editor.git_restore(&Default::default(), window, cx); + }); + + // Now, when all ranges selected belong to buffer_1, the revert should succeed, + // but not affect buffer_2 and its related excerpts. + editor.update(cx, |editor, cx| { + assert_eq!( + editor.display_text(cx), + "\n\naaaa\nbbbb\ncccc\ndddd\neeee\nffff\ngggg\nhhhh\niiii\njjjj\n\n\n\n\n\n\nXlll\nXmmm\nXnnn\n\nXqqq\nXrrr\n\nXuuu\n\n\nXvvv\nXwww\nXxxx\n\nX{{{\nX|||\n\nX\u{7f}\u{7f}\u{7f}" + ); + }); + buffer_1.update(cx, |buffer, _| { + assert_eq!(buffer.text(), base_text_1); + }); + buffer_2.update(cx, |buffer, _| { + assert_eq!( + buffer.text(), + "Xlll\nXmmm\nXnnn\nXooo\nXppp\nXqqq\nXrrr\nXsss\nXttt\nXuuu" + ); + }); + buffer_3.update(cx, |buffer, _| { + assert_eq!( + buffer.text(), + "Xvvv\nXwww\nXxxx\nXyyy\nXzzz\nX{{{\nX|||\nX}}}\nX~~~\nX\u{7f}\u{7f}\u{7f}" + ); + }); + + fn edit_first_char_of_every_line(text: &str) -> String { + text.split('\n') + .map(|line| format!("X{}", &line[1..])) + .collect::>() + .join("\n") + } +} + +#[gpui::test] +async fn test_multibuffer_in_navigation_history(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let cols = 4; + let rows = 10; + let sample_text_1 = sample_text(rows, cols, 'a'); + assert_eq!( + sample_text_1, + "aaaa\nbbbb\ncccc\ndddd\neeee\nffff\ngggg\nhhhh\niiii\njjjj" + ); + let sample_text_2 = sample_text(rows, cols, 'l'); + assert_eq!( + sample_text_2, + "llll\nmmmm\nnnnn\noooo\npppp\nqqqq\nrrrr\nssss\ntttt\nuuuu" + ); + let sample_text_3 = sample_text(rows, cols, 'v'); + assert_eq!( + sample_text_3, + "vvvv\nwwww\nxxxx\nyyyy\nzzzz\n{{{{\n||||\n}}}}\n~~~~\n\u{7f}\u{7f}\u{7f}\u{7f}" + ); + + let buffer_1 = cx.new(|cx| Buffer::local(sample_text_1.clone(), cx)); + let buffer_2 = cx.new(|cx| Buffer::local(sample_text_2.clone(), cx)); + let buffer_3 = cx.new(|cx| Buffer::local(sample_text_3.clone(), cx)); + + let multi_buffer = cx.new(|cx| { + let mut multibuffer = MultiBuffer::new(ReadWrite); + multibuffer.set_excerpts_for_path( + PathKey::sorted(0), + buffer_1.clone(), + [ + Point::new(0, 0)..Point::new(2, 0), + Point::new(5, 0)..Point::new(6, 0), + Point::new(9, 0)..Point::new(9, 4), + ], + 0, + cx, + ); + multibuffer.set_excerpts_for_path( + PathKey::sorted(1), + buffer_2.clone(), + [ + Point::new(0, 0)..Point::new(2, 0), + Point::new(5, 0)..Point::new(6, 0), + Point::new(9, 0)..Point::new(9, 4), + ], + 0, + cx, + ); + multibuffer.set_excerpts_for_path( + PathKey::sorted(2), + buffer_3.clone(), + [ + Point::new(0, 0)..Point::new(2, 0), + Point::new(5, 0)..Point::new(6, 0), + Point::new(9, 0)..Point::new(9, 4), + ], + 0, + cx, + ); + multibuffer + }); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/a", + json!({ + "main.rs": sample_text_1, + "other.rs": sample_text_2, + "lib.rs": sample_text_3, + }), + ) + .await; + let project = Project::test(fs, ["/a".as_ref()], cx).await; + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(*window, cx); + let multi_buffer_editor = cx.new_window_entity(|window, cx| { + Editor::new( + EditorMode::full(), + multi_buffer, + Some(project.clone()), + window, + cx, + ) + }); + let multibuffer_item_id = workspace.update_in(cx, |workspace, window, cx| { + assert!( + workspace.active_item(cx).is_none(), + "active item should be None before the first item is added" + ); + workspace.add_item_to_active_pane( + Box::new(multi_buffer_editor.clone()), + None, + true, + window, + cx, + ); + let active_item = workspace + .active_item(cx) + .expect("should have an active item after adding the multi buffer"); + assert_eq!( + active_item.buffer_kind(cx), + ItemBufferKind::Multibuffer, + "A multi buffer was expected to active after adding" + ); + active_item.item_id() + }); + + cx.executor().run_until_parked(); + + multi_buffer_editor.update_in(cx, |editor, window, cx| { + editor.change_selections( + SelectionEffects::scroll(Autoscroll::Next), + window, + cx, + |s| s.select_ranges(Some(MultiBufferOffset(1)..MultiBufferOffset(2))), + ); + editor.open_excerpts(&OpenExcerpts, window, cx); + }); + cx.executor().run_until_parked(); + let first_item_id = workspace.update_in(cx, |workspace, window, cx| { + let active_item = workspace + .active_item(cx) + .expect("should have an active item after navigating into the 1st buffer"); + let first_item_id = active_item.item_id(); + assert_ne!( + first_item_id, multibuffer_item_id, + "Should navigate into the 1st buffer and activate it" + ); + assert_eq!( + active_item.buffer_kind(cx), + ItemBufferKind::Singleton, + "New active item should be a singleton buffer" + ); + assert_eq!( + active_item + .act_as::(cx) + .expect("should have navigated into an editor for the 1st buffer") + .read(cx) + .text(cx), + sample_text_1 + ); + + workspace + .go_back(workspace.active_pane().downgrade(), window, cx) + .detach_and_log_err(cx); + + first_item_id + }); + + cx.executor().run_until_parked(); + workspace.update_in(cx, |workspace, _, cx| { + let active_item = workspace + .active_item(cx) + .expect("should have an active item after navigating back"); + assert_eq!( + active_item.item_id(), + multibuffer_item_id, + "Should navigate back to the multi buffer" + ); + assert_eq!(active_item.buffer_kind(cx), ItemBufferKind::Multibuffer); + }); + + multi_buffer_editor.update_in(cx, |editor, window, cx| { + editor.change_selections( + SelectionEffects::scroll(Autoscroll::Next), + window, + cx, + |s| s.select_ranges(Some(MultiBufferOffset(39)..MultiBufferOffset(40))), + ); + editor.open_excerpts(&OpenExcerpts, window, cx); + }); + cx.executor().run_until_parked(); + let second_item_id = workspace.update_in(cx, |workspace, window, cx| { + let active_item = workspace + .active_item(cx) + .expect("should have an active item after navigating into the 2nd buffer"); + let second_item_id = active_item.item_id(); + assert_ne!( + second_item_id, multibuffer_item_id, + "Should navigate away from the multibuffer" + ); + assert_ne!( + second_item_id, first_item_id, + "Should navigate into the 2nd buffer and activate it" + ); + assert_eq!( + active_item.buffer_kind(cx), + ItemBufferKind::Singleton, + "New active item should be a singleton buffer" + ); + assert_eq!( + active_item + .act_as::(cx) + .expect("should have navigated into an editor") + .read(cx) + .text(cx), + sample_text_2 + ); + + workspace + .go_back(workspace.active_pane().downgrade(), window, cx) + .detach_and_log_err(cx); + + second_item_id + }); + + cx.executor().run_until_parked(); + workspace.update_in(cx, |workspace, _, cx| { + let active_item = workspace + .active_item(cx) + .expect("should have an active item after navigating back from the 2nd buffer"); + assert_eq!( + active_item.item_id(), + multibuffer_item_id, + "Should navigate back from the 2nd buffer to the multi buffer" + ); + assert_eq!(active_item.buffer_kind(cx), ItemBufferKind::Multibuffer); + }); + + multi_buffer_editor.update_in(cx, |editor, window, cx| { + editor.change_selections( + SelectionEffects::scroll(Autoscroll::Next), + window, + cx, + |s| s.select_ranges(Some(MultiBufferOffset(70)..MultiBufferOffset(70))), + ); + editor.open_excerpts(&OpenExcerpts, window, cx); + }); + cx.executor().run_until_parked(); + workspace.update_in(cx, |workspace, window, cx| { + let active_item = workspace + .active_item(cx) + .expect("should have an active item after navigating into the 3rd buffer"); + let third_item_id = active_item.item_id(); + assert_ne!( + third_item_id, multibuffer_item_id, + "Should navigate into the 3rd buffer and activate it" + ); + assert_ne!(third_item_id, first_item_id); + assert_ne!(third_item_id, second_item_id); + assert_eq!( + active_item.buffer_kind(cx), + ItemBufferKind::Singleton, + "New active item should be a singleton buffer" + ); + assert_eq!( + active_item + .act_as::(cx) + .expect("should have navigated into an editor") + .read(cx) + .text(cx), + sample_text_3 + ); + + workspace + .go_back(workspace.active_pane().downgrade(), window, cx) + .detach_and_log_err(cx); + }); + + cx.executor().run_until_parked(); + workspace.update_in(cx, |workspace, _, cx| { + let active_item = workspace + .active_item(cx) + .expect("should have an active item after navigating back from the 3rd buffer"); + assert_eq!( + active_item.item_id(), + multibuffer_item_id, + "Should navigate back from the 3rd buffer to the multi buffer" + ); + assert_eq!(active_item.buffer_kind(cx), ItemBufferKind::Multibuffer); + }); +} + +#[gpui::test] +async fn test_merge_base_diff_hunks_are_read_only(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/project"), + json!({ + ".git": {}, + "file.rs": "worktree\n", + }), + ) + .await; + fs.set_head_and_index_for_repo( + std::path::Path::new(path!("/project/.git")), + &[("file.rs", "head\n".to_string())], + ); + fs.set_merge_base_content_for_repo( + std::path::Path::new(path!("/project/.git")), + &[("file.rs", "base\n".to_string())], + ); + + let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; + project + .update(cx, |project, cx| project.git_scans_complete(cx)) + .await; + let buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/project/file.rs"), cx) + }) + .await + .unwrap(); + let buffer_id = buffer.read_with(cx, |buffer, _| buffer.remote_id()); + let multi_buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + let (editor, cx) = cx.add_window_view(|window, cx| { + build_editor_with_project(project.clone(), multi_buffer, window, cx) + }); + + cx.update(|_window, cx| { + SettingsStore::update_global(cx, |settings, cx| { + settings.update_user_settings(cx, |settings| { + settings.git.get_or_insert_default().diff_base = + Some(settings::GitDiffBaseSetting::DefaultBranch); + }); + }); + }); + cx.run_until_parked(); + + editor.read_with(cx, |editor, cx| { + let diff = editor + .buffer() + .read(cx) + .diff_for(buffer_id) + .expect("buffer should have a display diff"); + assert!(diff.read(cx).operations().is_none()); + }); + editor.update_in(cx, |editor, window, cx| { + editor.select_all(&SelectAll, window, cx); + editor.git_restore(&Default::default(), window, cx); + editor.toggle_staged_selected_diff_hunks(&Default::default(), window, cx); + }); + cx.run_until_parked(); + + assert_eq!( + editor.read_with(cx, |editor, cx| editor.text(cx)), + "worktree\n" + ); + let index_contents = fs + .with_git_state( + std::path::Path::new(path!("/project/.git")), + false, + |state| state.index_contents.clone(), + ) + .unwrap(); + assert_eq!( + index_contents, + [(::git::repository::repo_path("file.rs"), b"head\n".to_vec(),)] + .into_iter() + .collect::>() + ); +} + +#[gpui::test] +async fn test_toggle_selected_diff_hunks(executor: BackgroundExecutor, cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + let diff_base = r#" + use some::mod; + + const A: u32 = 42; + + fn main() { + println!("hello"); + + println!("world"); + } + "# + .unindent(); + + cx.set_state( + &r#" + use some::modified; + + ˇ + fn main() { + println!("hello there"); + + println!("around the"); + println!("world"); + } + "# + .unindent(), + ); + + cx.set_head_text(&diff_base); + executor.run_until_parked(); + + cx.update_editor(|editor, window, cx| { + editor.go_to_next_hunk(&GoToHunk, window, cx); + editor.toggle_selected_diff_hunks(&ToggleSelectedDiffHunks, window, cx); + }); + executor.run_until_parked(); + cx.assert_state_with_diff( + r#" + use some::modified; + + + fn main() { + - println!("hello"); + + ˇ println!("hello there"); + + println!("around the"); + println!("world"); + } + "# + .unindent(), + ); + + cx.update_editor(|editor, window, cx| { + for _ in 0..2 { + editor.go_to_next_hunk(&GoToHunk, window, cx); + editor.toggle_selected_diff_hunks(&ToggleSelectedDiffHunks, window, cx); + } + }); + executor.run_until_parked(); + cx.assert_state_with_diff( + r#" + - use some::mod; + + ˇuse some::modified; + + + fn main() { + - println!("hello"); + + println!("hello there"); + + + println!("around the"); + println!("world"); + } + "# + .unindent(), + ); + + cx.update_editor(|editor, window, cx| { + editor.go_to_next_hunk(&GoToHunk, window, cx); + editor.toggle_selected_diff_hunks(&ToggleSelectedDiffHunks, window, cx); + }); + executor.run_until_parked(); + cx.assert_state_with_diff( + r#" + - use some::mod; + + use some::modified; + + - const A: u32 = 42; + ˇ + fn main() { + - println!("hello"); + + println!("hello there"); + + + println!("around the"); + println!("world"); + } + "# + .unindent(), + ); + + cx.update_editor(|editor, window, cx| { + editor.cancel(&Cancel, window, cx); + }); + + cx.assert_state_with_diff( + r#" + use some::modified; + + ˇ + fn main() { + println!("hello there"); + + println!("around the"); + println!("world"); + } + "# + .unindent(), + ); +} + +#[gpui::test] +async fn test_diff_base_change_with_expanded_diff_hunks( + executor: BackgroundExecutor, + cx: &mut TestAppContext, +) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + let diff_base = r#" + use some::mod1; + use some::mod2; + + const A: u32 = 42; + const B: u32 = 42; + const C: u32 = 42; + + fn main() { + println!("hello"); + + println!("world"); + } + "# + .unindent(); + + cx.set_state( + &r#" + use some::mod2; + + const A: u32 = 42; + const C: u32 = 42; + + fn main(ˇ) { + //println!("hello"); + + println!("world"); + // + // + } + "# + .unindent(), + ); + + cx.set_head_text(&diff_base); + executor.run_until_parked(); + + cx.update_editor(|editor, window, cx| { + editor.expand_all_diff_hunks(&ExpandAllDiffHunks, window, cx); + }); + executor.run_until_parked(); + cx.assert_state_with_diff( + r#" + - use some::mod1; + use some::mod2; + + const A: u32 = 42; + - const B: u32 = 42; + const C: u32 = 42; + + fn main(ˇ) { + - println!("hello"); + + //println!("hello"); + + println!("world"); + + // + + // + } + "# + .unindent(), + ); + + cx.set_head_text("new diff base!"); + executor.run_until_parked(); + cx.assert_state_with_diff( + r#" + - new diff base! + + use some::mod2; + + + + const A: u32 = 42; + + const C: u32 = 42; + + + + fn main(ˇ) { + + //println!("hello"); + + + + println!("world"); + + // + + // + + } + "# + .unindent(), + ); +} + +#[gpui::test] +async fn test_row_highlights_for_empty_conflict_side_after_edit( + executor: BackgroundExecutor, + cx: &mut TestAppContext, +) { + struct ReversedHighlight; + + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + + cx.set_state( + &r#" + ˇ<<<<<<< HEAD + ======= + theirs + >>>>>>> branch + after + "# + .unindent(), + ); + executor.run_until_parked(); + + // Mirrors what `git_ui::conflict_view::update_conflict_highlighting` does + // with the ranges produced by the conflict parser, and additionally + // stores the inside-out range that empty sides used to produce. + cx.update_editor(|editor, _, cx| { + let buffer = editor.buffer().read(cx).as_singleton().unwrap(); + let conflicts = project::ConflictSet::parse(&buffer.read(cx).text_snapshot()); + assert_eq!(conflicts.conflicts.len(), 1); + let conflict = &conflicts.conflicts[0]; + let snapshot = editor.buffer().read(cx).snapshot(cx); + let ours = snapshot + .buffer_anchor_range_to_anchor_range(conflict.ours.clone()) + .unwrap(); + assert!(ours.start.cmp(&ours.end, &snapshot).is_le()); + editor.highlight_rows::( + ours, + |cx| cx.theme().colors().version_control_conflict_marker_ours, + RowHighlightOptions { + include_gutter: true, + ..Default::default() + }, + cx, + ); + + let marker_start = Point::new(1, 0); + editor.highlight_rows::( + snapshot.anchor_after(marker_start)..snapshot.anchor_before(marker_start), + |cx| cx.theme().colors().editor_highlighted_line_background, + RowHighlightOptions::default(), + cx, + ); + }); + + // The user types a resolution into the empty "ours" side, on the line + // where the `=======` marker currently sits. + cx.update_editor(|editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| { + selections.select_ranges([Point::new(1, 0)..Point::new(1, 0)]); + }); + editor.handle_input("one\ntwo\nthree\nfour\nfive\nsix\n", window, cx); + }); + + // Render every possible viewport, the same way `EditorElement::prepaint` + // does, before the conflict set has been re-parsed. The typed rows carry + // the "ours" highlight; the inside-out range never contributes. + cx.update_editor(|editor, window, cx| { + let snapshot = editor.snapshot(window, cx).display_snapshot; + let ours_type_id = Some(TypeId::of::()); + for start_row in 0..snapshot.max_point().row().0 { + for end_row in start_row + 1..=snapshot.max_point().row().0 { + let start = snapshot + .buffer_snapshot() + .anchor_before(Point::new(start_row, 0)); + let end = snapshot + .buffer_snapshot() + .anchor_before(Point::new(end_row, 0)); + let highlighted_rows = editor.highlighted_display_rows_in_range( + start..end, + DisplayRow(start_row)..DisplayRow(end_row), + &snapshot, + cx, + ); + let expected_rows = (start_row.max(1)..end_row.min(7)) + .map(DisplayRow) + .collect::>(); + assert_eq!( + highlighted_rows.keys().copied().collect::>(), + expected_rows, + "viewport {start_row}..{end_row}" + ); + assert!( + highlighted_rows + .values() + .all(|highlight| highlight.type_id == ours_type_id), + "viewport {start_row}..{end_row}" + ); + } + } + }); +} + +#[gpui::test] +async fn test_go_to_singleton_buffer_point_with_expanded_deleted_hunks( + executor: BackgroundExecutor, + cx: &mut TestAppContext, +) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + let diff_base = r#" + removed line 1 + removed line 2 + line 1 + line 2 + line 3 + "# + .unindent(); + + cx.set_state( + &r#" + ˇline 1 + line 2 + line 3 + "# + .unindent(), + ); + + cx.set_head_text(&diff_base); + executor.run_until_parked(); + + cx.update_editor(|editor, window, cx| { + editor.expand_all_diff_hunks(&ExpandAllDiffHunks, window, cx); + }); + executor.run_until_parked(); + + cx.update_editor(|editor, window, cx| { + editor.go_to_singleton_buffer_point(Point::new(2, 0), window, cx); + }); + + cx.assert_state_with_diff( + r#" + - removed line 1 + - removed line 2 + line 1 + line 2 + ˇline 3 + "# + .unindent(), + ); +} + +#[gpui::test] +async fn test_toggle_diff_expand_in_multi_buffer(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let file_1_old = "aaa\nbbb\nccc\nddd\neee\nfff\nggg\nhhh\niii\njjj"; + let file_1_new = "aaa\nccc\nddd\neee\nfff\nggg\nhhh\niii\njjj"; + let file_2_old = "lll\nmmm\nnnn\nooo\nppp\nqqq\nrrr\nsss\nttt\nuuu"; + let file_2_new = "lll\nmmm\nNNN\nooo\nppp\nqqq\nrrr\nsss\nttt\nuuu"; + let file_3_old = "111\n222\n333\n444\n555\n777\n888\n999\n000\n!!!"; + let file_3_new = "111\n222\n333\n444\n555\n666\n777\n888\n999\n000\n!!!"; + + let buffer_1 = cx.new(|cx| Buffer::local(file_1_new.to_string(), cx)); + let buffer_2 = cx.new(|cx| Buffer::local(file_2_new.to_string(), cx)); + let buffer_3 = cx.new(|cx| Buffer::local(file_3_new.to_string(), cx)); + + let multi_buffer = cx.new(|cx| { + let mut multibuffer = MultiBuffer::new(ReadWrite); + multibuffer.set_excerpts_for_path( + PathKey::sorted(0), + buffer_1.clone(), + [ + Point::new(0, 0)..Point::new(2, 3), + Point::new(5, 0)..Point::new(6, 3), + Point::new(9, 0)..Point::new(10, 3), + ], + 0, + cx, + ); + multibuffer.set_excerpts_for_path( + PathKey::sorted(1), + buffer_2.clone(), + [ + Point::new(0, 0)..Point::new(2, 3), + Point::new(5, 0)..Point::new(6, 3), + Point::new(9, 0)..Point::new(10, 3), + ], + 0, + cx, + ); + multibuffer.set_excerpts_for_path( + PathKey::sorted(2), + buffer_3.clone(), + [ + Point::new(0, 0)..Point::new(2, 3), + Point::new(5, 0)..Point::new(6, 3), + Point::new(9, 0)..Point::new(10, 3), + ], + 0, + cx, + ); + assert_eq!(multibuffer.read(cx).excerpts().count(), 9); + multibuffer + }); + + let editor = + cx.add_window(|window, cx| Editor::new(EditorMode::full(), multi_buffer, None, window, cx)); + editor + .update(cx, |editor, _window, cx| { + for (buffer, diff_base) in [ + (buffer_1.clone(), file_1_old), + (buffer_2.clone(), file_2_old), + (buffer_3.clone(), file_3_old), + ] { + let diff = cx.new(|cx| { + BufferDiff::new_with_base_text(diff_base, &buffer.read(cx).text_snapshot(), cx) + }); + editor + .buffer + .update(cx, |buffer, cx| buffer.add_diff(diff, cx)); + } + }) + .unwrap(); + + let mut cx = EditorTestContext::for_editor(editor, cx).await; + cx.run_until_parked(); + + cx.assert_editor_state( + &" + ˇaaa + ccc + ddd + ggg + hhh + + lll + mmm + NNN + qqq + rrr + uuu + 111 + 222 + 333 + 666 + 777 + 000 + !!!" + .unindent(), + ); + + cx.update_editor(|editor, window, cx| { + editor.select_all(&SelectAll, window, cx); + editor.toggle_selected_diff_hunks(&ToggleSelectedDiffHunks, window, cx); + }); + cx.executor().run_until_parked(); + + cx.assert_state_with_diff( + " + «aaa + - bbb + ccc + ddd + ggg + hhh + + lll + mmm + - nnn + + NNN + qqq + rrr + uuu + 111 + 222 + 333 + + 666 + 777 + 000 + !!!ˇ»" + .unindent(), + ); +} + +#[gpui::test] +async fn test_expand_diff_hunk_at_excerpt_boundary(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let base = "aaa\nbbb\nccc\nddd\neee\nfff\nggg\n"; + let text = "aaa\nBBB\nBB2\nccc\nDDD\nEEE\nfff\nggg\nhhh\niii\n"; + + let buffer = cx.new(|cx| Buffer::local(text.to_string(), cx)); + let multi_buffer = cx.new(|cx| { + let mut multibuffer = MultiBuffer::new(ReadWrite); + multibuffer.set_excerpts_for_path( + PathKey::sorted(0), + buffer.clone(), + [ + Point::new(0, 0)..Point::new(1, 3), + Point::new(4, 0)..Point::new(6, 3), + Point::new(9, 0)..Point::new(9, 3), + ], + 0, + cx, + ); + assert_eq!(multibuffer.read(cx).excerpts().count(), 3); + multibuffer + }); + + let editor = + cx.add_window(|window, cx| Editor::new(EditorMode::full(), multi_buffer, None, window, cx)); + editor + .update(cx, |editor, _window, cx| { + let diff = cx.new(|cx| { + BufferDiff::new_with_base_text(base, &buffer.read(cx).text_snapshot(), cx) + }); + editor + .buffer + .update(cx, |buffer, cx| buffer.add_diff(diff, cx)) + }) + .unwrap(); + + let mut cx = EditorTestContext::for_editor(editor, cx).await; + cx.run_until_parked(); + + cx.update_editor(|editor, window, cx| { + editor.expand_all_diff_hunks(&Default::default(), window, cx) + }); + cx.executor().run_until_parked(); + + // When the start of a hunk coincides with the start of its excerpt, + // the hunk is expanded. When the start of a hunk is earlier than + // the start of its excerpt, the hunk is not expanded. + cx.assert_state_with_diff( + " + ˇaaa + - bbb + + BBB + - ddd + - eee + + DDD + + EEE + fff + iii" + .unindent(), + ); +} + +#[gpui::test] +async fn test_edits_around_expanded_insertion_hunks( + executor: BackgroundExecutor, + cx: &mut TestAppContext, +) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + let diff_base = r#" + use some::mod1; + use some::mod2; + + const A: u32 = 42; + + fn main() { + println!("hello"); + + println!("world"); + } + "# + .unindent(); + executor.run_until_parked(); + cx.set_state( + &r#" + use some::mod1; + use some::mod2; + + const A: u32 = 42; + const B: u32 = 42; + const C: u32 = 42; + ˇ + + fn main() { + println!("hello"); + + println!("world"); + } + "# + .unindent(), + ); + + cx.set_head_text(&diff_base); + executor.run_until_parked(); + + cx.update_editor(|editor, window, cx| { + editor.expand_all_diff_hunks(&ExpandAllDiffHunks, window, cx); + }); + executor.run_until_parked(); + + cx.assert_state_with_diff( + r#" + use some::mod1; + use some::mod2; + + const A: u32 = 42; + + const B: u32 = 42; + + const C: u32 = 42; + + ˇ + + fn main() { + println!("hello"); + + println!("world"); + } + "# + .unindent(), + ); + + cx.update_editor(|editor, window, cx| editor.handle_input("const D: u32 = 42;\n", window, cx)); + executor.run_until_parked(); + + cx.assert_state_with_diff( + r#" + use some::mod1; + use some::mod2; + + const A: u32 = 42; + + const B: u32 = 42; + + const C: u32 = 42; + + const D: u32 = 42; + + ˇ + + fn main() { + println!("hello"); + + println!("world"); + } + "# + .unindent(), + ); + + cx.update_editor(|editor, window, cx| editor.handle_input("const E: u32 = 42;\n", window, cx)); + executor.run_until_parked(); + + cx.assert_state_with_diff( + r#" + use some::mod1; + use some::mod2; + + const A: u32 = 42; + + const B: u32 = 42; + + const C: u32 = 42; + + const D: u32 = 42; + + const E: u32 = 42; + + ˇ + + fn main() { + println!("hello"); + + println!("world"); + } + "# + .unindent(), + ); + + cx.update_editor(|editor, window, cx| { + editor.delete_line(&DeleteLine, window, cx); + }); + executor.run_until_parked(); + + cx.assert_state_with_diff( + r#" + use some::mod1; + use some::mod2; + + const A: u32 = 42; + + const B: u32 = 42; + + const C: u32 = 42; + + const D: u32 = 42; + + const E: u32 = 42; + ˇ + fn main() { + println!("hello"); + + println!("world"); + } + "# + .unindent(), + ); + + cx.update_editor(|editor, window, cx| { + editor.move_up(&MoveUp, window, cx); + editor.delete_line(&DeleteLine, window, cx); + editor.move_up(&MoveUp, window, cx); + editor.delete_line(&DeleteLine, window, cx); + editor.move_up(&MoveUp, window, cx); + editor.delete_line(&DeleteLine, window, cx); + }); + executor.run_until_parked(); + cx.assert_state_with_diff( + r#" + use some::mod1; + use some::mod2; + + const A: u32 = 42; + + const B: u32 = 42; + ˇ + fn main() { + println!("hello"); + + println!("world"); + } + "# + .unindent(), + ); + + cx.update_editor(|editor, window, cx| { + editor.select_up_by_lines(&SelectUpByLines { lines: 5 }, window, cx); + editor.delete_line(&DeleteLine, window, cx); + }); + executor.run_until_parked(); + cx.assert_state_with_diff( + r#" + ˇ + fn main() { + println!("hello"); + + println!("world"); + } + "# + .unindent(), + ); +} + +#[gpui::test] +async fn test_toggling_adjacent_diff_hunks(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + cx.set_head_text(indoc! { " + one + two + three + four + five + " + }); + cx.set_state(indoc! { " + one + ˇthree + five + "}); + cx.run_until_parked(); + cx.update_editor(|editor, window, cx| { + editor.toggle_selected_diff_hunks(&Default::default(), window, cx); + }); + cx.assert_state_with_diff( + indoc! { " + one + - two + ˇthree + - four + five + "} + .to_string(), + ); + cx.update_editor(|editor, window, cx| { + editor.toggle_selected_diff_hunks(&Default::default(), window, cx); + }); + + cx.assert_state_with_diff( + indoc! { " + one + ˇthree + five + "} + .to_string(), + ); + + cx.update_editor(|editor, window, cx| { + editor.move_up(&MoveUp, window, cx); + editor.toggle_selected_diff_hunks(&Default::default(), window, cx); + }); + cx.assert_state_with_diff( + indoc! { " + ˇone + - two + three + five + "} + .to_string(), + ); + + cx.update_editor(|editor, window, cx| { + editor.move_down(&MoveDown, window, cx); + editor.move_down(&MoveDown, window, cx); + editor.toggle_selected_diff_hunks(&Default::default(), window, cx); + }); + cx.assert_state_with_diff( + indoc! { " + one + - two + ˇthree + - four + five + "} + .to_string(), + ); + + cx.set_state(indoc! { " + one + ˇTWO + three + four + five + "}); + cx.run_until_parked(); + cx.update_editor(|editor, window, cx| { + editor.toggle_selected_diff_hunks(&Default::default(), window, cx); + }); + + cx.assert_state_with_diff( + indoc! { " + one + - two + + ˇTWO + three + four + five + "} + .to_string(), + ); + cx.update_editor(|editor, window, cx| { + editor.move_up(&Default::default(), window, cx); + editor.toggle_selected_diff_hunks(&Default::default(), window, cx); + }); + cx.assert_state_with_diff( + indoc! { " + one + ˇTWO + three + four + five + "} + .to_string(), + ); +} + +#[gpui::test] +async fn test_toggling_adjacent_diff_hunks_2( + executor: BackgroundExecutor, + cx: &mut TestAppContext, +) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + let diff_base = r#" + lineA + lineB + lineC + lineD + "# + .unindent(); + + cx.set_state( + &r#" + ˇlineA1 + lineB + lineD + "# + .unindent(), + ); + cx.set_head_text(&diff_base); + executor.run_until_parked(); + + cx.update_editor(|editor, window, cx| { + editor.toggle_selected_diff_hunks(&ToggleSelectedDiffHunks, window, cx); + }); + executor.run_until_parked(); + cx.assert_state_with_diff( + r#" + - lineA + + ˇlineA1 + lineB + lineD + "# + .unindent(), + ); + + cx.update_editor(|editor, window, cx| { + editor.move_down(&MoveDown, window, cx); + editor.move_right(&MoveRight, window, cx); + editor.toggle_selected_diff_hunks(&ToggleSelectedDiffHunks, window, cx); + }); + executor.run_until_parked(); + cx.assert_state_with_diff( + r#" + - lineA + + lineA1 + lˇineB + - lineC + lineD + "# + .unindent(), + ); +} + +#[gpui::test] +async fn test_edits_around_expanded_deletion_hunks( + executor: BackgroundExecutor, + cx: &mut TestAppContext, +) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + let diff_base = r#" + use some::mod1; + use some::mod2; + + const A: u32 = 42; + const B: u32 = 42; + const C: u32 = 42; + + + fn main() { + println!("hello"); + + println!("world"); + } + "# + .unindent(); + executor.run_until_parked(); + cx.set_state( + &r#" + use some::mod1; + use some::mod2; + + ˇconst B: u32 = 42; + const C: u32 = 42; + + + fn main() { + println!("hello"); + + println!("world"); + } + "# + .unindent(), + ); + + cx.set_head_text(&diff_base); + executor.run_until_parked(); + + cx.update_editor(|editor, window, cx| { + editor.expand_all_diff_hunks(&ExpandAllDiffHunks, window, cx); + }); + executor.run_until_parked(); + + cx.assert_state_with_diff( + r#" + use some::mod1; + use some::mod2; + + - const A: u32 = 42; + ˇconst B: u32 = 42; + const C: u32 = 42; + + + fn main() { + println!("hello"); + + println!("world"); + } + "# + .unindent(), + ); + + cx.update_editor(|editor, window, cx| { + editor.delete_line(&DeleteLine, window, cx); + }); + executor.run_until_parked(); + cx.assert_state_with_diff( + r#" + use some::mod1; + use some::mod2; + + - const A: u32 = 42; + - const B: u32 = 42; + ˇconst C: u32 = 42; + + + fn main() { + println!("hello"); + + println!("world"); + } + "# + .unindent(), + ); + + cx.update_editor(|editor, window, cx| { + editor.delete_line(&DeleteLine, window, cx); + }); + executor.run_until_parked(); + cx.assert_state_with_diff( + r#" + use some::mod1; + use some::mod2; + + - const A: u32 = 42; + - const B: u32 = 42; + - const C: u32 = 42; + ˇ + + fn main() { + println!("hello"); + + println!("world"); + } + "# + .unindent(), + ); + + cx.update_editor(|editor, window, cx| { + editor.handle_input("replacement", window, cx); + }); + executor.run_until_parked(); + cx.assert_state_with_diff( + r#" + use some::mod1; + use some::mod2; + + - const A: u32 = 42; + - const B: u32 = 42; + - const C: u32 = 42; + - + + replacementˇ + + fn main() { + println!("hello"); + + println!("world"); + } + "# + .unindent(), + ); +} + +#[gpui::test] +async fn test_backspace_after_deletion_hunk(executor: BackgroundExecutor, cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + let base_text = r#" + one + two + three + four + five + "# + .unindent(); + executor.run_until_parked(); + cx.set_state( + &r#" + one + two + fˇour + five + "# + .unindent(), + ); + + cx.set_head_text(&base_text); + executor.run_until_parked(); + + cx.update_editor(|editor, window, cx| { + editor.expand_all_diff_hunks(&ExpandAllDiffHunks, window, cx); + }); + executor.run_until_parked(); + + cx.assert_state_with_diff( + r#" + one + two + - three + fˇour + five + "# + .unindent(), + ); + + cx.update_editor(|editor, window, cx| { + editor.backspace(&Backspace, window, cx); + editor.backspace(&Backspace, window, cx); + }); + executor.run_until_parked(); + cx.assert_state_with_diff( + r#" + one + two + - threeˇ + - four + + our + five + "# + .unindent(), + ); +} + +#[gpui::test] +async fn test_edit_after_expanded_modification_hunk( + executor: BackgroundExecutor, + cx: &mut TestAppContext, +) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + let diff_base = r#" + use some::mod1; + use some::mod2; + + const A: u32 = 42; + const B: u32 = 42; + const C: u32 = 42; + const D: u32 = 42; + + + fn main() { + println!("hello"); + + println!("world"); + }"# + .unindent(); + + cx.set_state( + &r#" + use some::mod1; + use some::mod2; + + const A: u32 = 42; + const B: u32 = 42; + const C: u32 = 43ˇ + const D: u32 = 42; + + + fn main() { + println!("hello"); + + println!("world"); + }"# + .unindent(), + ); + + cx.set_head_text(&diff_base); + executor.run_until_parked(); + cx.update_editor(|editor, window, cx| { + editor.expand_all_diff_hunks(&ExpandAllDiffHunks, window, cx); + }); + executor.run_until_parked(); + + cx.assert_state_with_diff( + r#" + use some::mod1; + use some::mod2; + + const A: u32 = 42; + const B: u32 = 42; + - const C: u32 = 42; + + const C: u32 = 43ˇ + const D: u32 = 42; + + + fn main() { + println!("hello"); + + println!("world"); + }"# + .unindent(), + ); + + cx.update_editor(|editor, window, cx| { + editor.handle_input("\nnew_line\n", window, cx); + }); + executor.run_until_parked(); + + cx.assert_state_with_diff( + r#" + use some::mod1; + use some::mod2; + + const A: u32 = 42; + const B: u32 = 42; + - const C: u32 = 42; + + const C: u32 = 43 + + new_line + + ˇ + const D: u32 = 42; + + + fn main() { + println!("hello"); + + println!("world"); + }"# + .unindent(), + ); +} + +#[gpui::test] +async fn test_stage_and_unstage_added_file_hunk( + executor: BackgroundExecutor, + cx: &mut TestAppContext, +) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + cx.update_editor(|editor, _, cx| { + editor.set_expand_all_diff_hunks(cx); + }); + + let working_copy = r#" + ˇfn main() { + println!("hello, world!"); + } + "# + .unindent(); + + cx.set_state(&working_copy); + executor.run_until_parked(); + + cx.assert_state_with_diff( + r#" + + ˇfn main() { + + println!("hello, world!"); + + } + "# + .unindent(), + ); + cx.assert_index_text(None); + + cx.update_editor(|editor, window, cx| { + editor.toggle_staged_selected_diff_hunks(&Default::default(), window, cx); + }); + executor.run_until_parked(); + cx.assert_index_text(Some(&working_copy.replace("ˇ", ""))); + cx.assert_state_with_diff( + r#" + + ˇfn main() { + + println!("hello, world!"); + + } + "# + .unindent(), + ); + + cx.update_editor(|editor, window, cx| { + editor.toggle_staged_selected_diff_hunks(&Default::default(), window, cx); + }); + executor.run_until_parked(); + cx.assert_index_text(None); +} + +async fn setup_indent_guides_editor( + text: &str, + cx: &mut TestAppContext, +) -> (BufferId, EditorTestContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + let buffer_id = cx.update_editor(|editor, window, cx| { + editor.set_text(text, window, cx); + editor + .buffer() + .read(cx) + .as_singleton() + .unwrap() + .read(cx) + .remote_id() + }); + + (buffer_id, cx) +} + +fn assert_indent_guides( + range: Range, + expected: Vec, + active_indices: Option>, + cx: &mut EditorTestContext, +) { + let indent_guides = cx.update_editor(|editor, window, cx| { + let snapshot = editor.snapshot(window, cx).display_snapshot; + let mut indent_guides: Vec<_> = crate::indent_guides::indent_guides_in_range( + editor, + MultiBufferRow(range.start)..MultiBufferRow(range.end), + true, + &snapshot, + cx, + ); + + indent_guides.sort_by(|a, b| { + a.depth.cmp(&b.depth).then( + a.start_row + .cmp(&b.start_row) + .then(a.end_row.cmp(&b.end_row)), + ) + }); + indent_guides + }); + + if let Some(expected) = active_indices { + let active_indices = cx.update_editor(|editor, window, cx| { + let snapshot = editor.snapshot(window, cx).display_snapshot; + editor.find_active_indent_guide_indices(&indent_guides, &snapshot, window, cx) + }); + + assert_eq!( + active_indices.unwrap().into_iter().collect::>(), + expected, + "Active indent guide indices do not match" + ); + } + + assert_eq!(indent_guides, expected, "Indent guides do not match"); +} + +fn indent_guide(buffer_id: BufferId, start_row: u32, end_row: u32, depth: u32) -> IndentGuide { + IndentGuide { + buffer_id, + start_row: MultiBufferRow(start_row), + end_row: MultiBufferRow(end_row), + depth, + tab_size: 4, + settings: IndentGuideSettings { + enabled: true, + line_width: 1, + active_line_width: 1, + coloring: IndentGuideColoring::default(), + background_coloring: IndentGuideBackgroundColoring::default(), + }, + } +} + +#[gpui::test] +async fn test_indent_guide_single_line(cx: &mut TestAppContext) { + let (buffer_id, mut cx) = setup_indent_guides_editor( + &" + fn main() { + let a = 1; + }" + .unindent(), + cx, + ) + .await; + + assert_indent_guides(0..3, vec![indent_guide(buffer_id, 1, 1, 0)], None, &mut cx); +} + +#[gpui::test] +async fn test_indent_guide_simple_block(cx: &mut TestAppContext) { + let (buffer_id, mut cx) = setup_indent_guides_editor( + &" + fn main() { + let a = 1; + let b = 2; + }" + .unindent(), + cx, + ) + .await; + + assert_indent_guides(0..4, vec![indent_guide(buffer_id, 1, 2, 0)], None, &mut cx); +} + +#[gpui::test] +async fn test_indent_guide_nested(cx: &mut TestAppContext) { + let (buffer_id, mut cx) = setup_indent_guides_editor( + &" + fn main() { + let a = 1; + if a == 3 { + let b = 2; + } else { + let c = 3; + } + }" + .unindent(), + cx, + ) + .await; + + assert_indent_guides( + 0..8, + vec![ + indent_guide(buffer_id, 1, 6, 0), + indent_guide(buffer_id, 3, 3, 1), + indent_guide(buffer_id, 5, 5, 1), + ], + None, + &mut cx, + ); +} + +#[gpui::test] +async fn test_indent_guide_tab(cx: &mut TestAppContext) { + let (buffer_id, mut cx) = setup_indent_guides_editor( + &" + fn main() { + let a = 1; + let b = 2; + let c = 3; + }" + .unindent(), + cx, + ) + .await; + + assert_indent_guides( + 0..5, + vec![ + indent_guide(buffer_id, 1, 3, 0), + indent_guide(buffer_id, 2, 2, 1), + ], + None, + &mut cx, + ); +} + +#[gpui::test] +async fn test_indent_guide_continues_on_empty_line(cx: &mut TestAppContext) { + let (buffer_id, mut cx) = setup_indent_guides_editor( + &" + fn main() { + let a = 1; + + let c = 3; + }" + .unindent(), + cx, + ) + .await; + + assert_indent_guides(0..5, vec![indent_guide(buffer_id, 1, 3, 0)], None, &mut cx); +} + +#[gpui::test] +async fn test_indent_guide_complex(cx: &mut TestAppContext) { + let (buffer_id, mut cx) = setup_indent_guides_editor( + &" + fn main() { + let a = 1; + + let c = 3; + + if a == 3 { + let b = 2; + } else { + let c = 3; + } + }" + .unindent(), + cx, + ) + .await; + + assert_indent_guides( + 0..11, + vec![ + indent_guide(buffer_id, 1, 9, 0), + indent_guide(buffer_id, 6, 6, 1), + indent_guide(buffer_id, 8, 8, 1), + ], + None, + &mut cx, + ); +} + +#[gpui::test] +async fn test_indent_guide_starts_off_screen(cx: &mut TestAppContext) { + let (buffer_id, mut cx) = setup_indent_guides_editor( + &" + fn main() { + let a = 1; + + let c = 3; + + if a == 3 { + let b = 2; + } else { + let c = 3; + } + }" + .unindent(), + cx, + ) + .await; + + assert_indent_guides( + 1..11, + vec![ + indent_guide(buffer_id, 1, 9, 0), + indent_guide(buffer_id, 6, 6, 1), + indent_guide(buffer_id, 8, 8, 1), + ], + None, + &mut cx, + ); +} + +#[gpui::test] +async fn test_indent_guide_ends_off_screen(cx: &mut TestAppContext) { + let (buffer_id, mut cx) = setup_indent_guides_editor( + &" + fn main() { + let a = 1; + + let c = 3; + + if a == 3 { + let b = 2; + } else { + let c = 3; + } + }" + .unindent(), + cx, + ) + .await; + + assert_indent_guides( + 1..10, + vec![ + indent_guide(buffer_id, 1, 9, 0), + indent_guide(buffer_id, 6, 6, 1), + indent_guide(buffer_id, 8, 8, 1), + ], + None, + &mut cx, + ); +} + +#[gpui::test] +async fn test_indent_guide_with_folds(cx: &mut TestAppContext) { + let (buffer_id, mut cx) = setup_indent_guides_editor( + &" + fn main() { + if a { + b( + c, + d, + ) + } else { + e( + f + ) + } + }" + .unindent(), + cx, + ) + .await; + + assert_indent_guides( + 0..11, + vec![ + indent_guide(buffer_id, 1, 10, 0), + indent_guide(buffer_id, 2, 5, 1), + indent_guide(buffer_id, 7, 9, 1), + indent_guide(buffer_id, 3, 4, 2), + indent_guide(buffer_id, 8, 8, 2), + ], + None, + &mut cx, + ); + + cx.update_editor(|editor, window, cx| { + editor.fold_at(MultiBufferRow(2), window, cx); + assert_eq!( + editor.display_text(cx), + " + fn main() { + if a { + b(⋯) + } else { + e( + f + ) + } + }" + .unindent() + ); + }); + + assert_indent_guides( + 0..11, + vec![ + indent_guide(buffer_id, 1, 10, 0), + indent_guide(buffer_id, 2, 5, 1), + indent_guide(buffer_id, 7, 9, 1), + indent_guide(buffer_id, 8, 8, 2), + ], + None, + &mut cx, + ); +} + +#[gpui::test] +async fn test_indent_guide_without_brackets(cx: &mut TestAppContext) { + let (buffer_id, mut cx) = setup_indent_guides_editor( + &" + block1 + block2 + block3 + block4 + block2 + block1 + block1" + .unindent(), + cx, + ) + .await; + + assert_indent_guides( + 1..10, + vec![ + indent_guide(buffer_id, 1, 4, 0), + indent_guide(buffer_id, 2, 3, 1), + indent_guide(buffer_id, 3, 3, 2), + ], + None, + &mut cx, + ); +} + +#[gpui::test] +async fn test_indent_guide_ends_before_empty_line(cx: &mut TestAppContext) { + let (buffer_id, mut cx) = setup_indent_guides_editor( + &" + block1 + block2 + block3 + + block1 + block1" + .unindent(), + cx, + ) + .await; + + assert_indent_guides( + 0..6, + vec![ + indent_guide(buffer_id, 1, 2, 0), + indent_guide(buffer_id, 2, 2, 1), + ], + None, + &mut cx, + ); +} + +#[gpui::test] +async fn test_indent_guide_ignored_only_whitespace_lines(cx: &mut TestAppContext) { + let (buffer_id, mut cx) = setup_indent_guides_editor( + &" + function component() { + \treturn ( + \t\t\t + \t\t

+ \t\t\t + \t\t
+ \t) + }" + .unindent(), + cx, + ) + .await; + + assert_indent_guides( + 0..8, + vec![ + indent_guide(buffer_id, 1, 6, 0), + indent_guide(buffer_id, 2, 5, 1), + indent_guide(buffer_id, 4, 4, 2), + ], + None, + &mut cx, + ); +} + +#[gpui::test] +async fn test_indent_guide_fallback_to_next_non_entirely_whitespace_line(cx: &mut TestAppContext) { + let (buffer_id, mut cx) = setup_indent_guides_editor( + &" + function component() { + \treturn ( + \t + \t\t
+ \t\t\t + \t\t
+ \t) + }" + .unindent(), + cx, + ) + .await; + + assert_indent_guides( + 0..8, + vec![ + indent_guide(buffer_id, 1, 6, 0), + indent_guide(buffer_id, 2, 5, 1), + indent_guide(buffer_id, 4, 4, 2), + ], + None, + &mut cx, + ); +} + +#[gpui::test] +async fn test_indent_guide_continuing_off_screen(cx: &mut TestAppContext) { + let (buffer_id, mut cx) = setup_indent_guides_editor( + &" + block1 + + + + block2 + " + .unindent(), + cx, + ) + .await; + + assert_indent_guides(0..1, vec![indent_guide(buffer_id, 1, 1, 0)], None, &mut cx); +} + +#[gpui::test] +async fn test_indent_guide_tabs(cx: &mut TestAppContext) { + let (buffer_id, mut cx) = setup_indent_guides_editor( + &" + def a: + \tb = 3 + \tif True: + \t\tc = 4 + \t\td = 5 + \tprint(b) + " + .unindent(), + cx, + ) + .await; + + assert_indent_guides( + 0..6, + vec![ + indent_guide(buffer_id, 1, 5, 0), + indent_guide(buffer_id, 3, 4, 1), + ], + None, + &mut cx, + ); +} + +#[gpui::test] +async fn test_active_indent_guide_single_line(cx: &mut TestAppContext) { + let (buffer_id, mut cx) = setup_indent_guides_editor( + &" + fn main() { + let a = 1; + }" + .unindent(), + cx, + ) + .await; + + cx.update_editor(|editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_ranges([Point::new(1, 0)..Point::new(1, 0)]) + }); + }); + + assert_indent_guides( + 0..3, + vec![indent_guide(buffer_id, 1, 1, 0)], + Some(vec![0]), + &mut cx, + ); +} + +#[gpui::test] +async fn test_active_indent_guide_respect_indented_range(cx: &mut TestAppContext) { + let (buffer_id, mut cx) = setup_indent_guides_editor( + &" + fn main() { + if 1 == 2 { + let a = 1; + } + }" + .unindent(), + cx, + ) + .await; + + cx.update_editor(|editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_ranges([Point::new(1, 0)..Point::new(1, 0)]) + }); + }); + cx.run_until_parked(); + + assert_indent_guides( + 0..4, + vec![ + indent_guide(buffer_id, 1, 3, 0), + indent_guide(buffer_id, 2, 2, 1), + ], + Some(vec![1]), + &mut cx, + ); + + cx.update_editor(|editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_ranges([Point::new(2, 0)..Point::new(2, 0)]) + }); + }); + cx.run_until_parked(); + + assert_indent_guides( + 0..4, + vec![ + indent_guide(buffer_id, 1, 3, 0), + indent_guide(buffer_id, 2, 2, 1), + ], + Some(vec![1]), + &mut cx, + ); + + cx.update_editor(|editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_ranges([Point::new(3, 0)..Point::new(3, 0)]) + }); + }); + cx.run_until_parked(); + + assert_indent_guides( + 0..4, + vec![ + indent_guide(buffer_id, 1, 3, 0), + indent_guide(buffer_id, 2, 2, 1), + ], + Some(vec![0]), + &mut cx, + ); +} + +#[gpui::test] +async fn test_active_indent_guide_empty_line(cx: &mut TestAppContext) { + let (buffer_id, mut cx) = setup_indent_guides_editor( + &" + fn main() { + let a = 1; + + let b = 2; + }" + .unindent(), + cx, + ) + .await; + + cx.update_editor(|editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_ranges([Point::new(2, 0)..Point::new(2, 0)]) + }); + }); + + assert_indent_guides( + 0..5, + vec![indent_guide(buffer_id, 1, 3, 0)], + Some(vec![0]), + &mut cx, + ); +} + +#[gpui::test] +async fn test_active_indent_guide_non_matching_indent(cx: &mut TestAppContext) { + let (buffer_id, mut cx) = setup_indent_guides_editor( + &" + def m: + a = 1 + pass" + .unindent(), + cx, + ) + .await; + + cx.update_editor(|editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_ranges([Point::new(1, 0)..Point::new(1, 0)]) + }); + }); + + assert_indent_guides( + 0..3, + vec![indent_guide(buffer_id, 1, 2, 0)], + Some(vec![0]), + &mut cx, + ); +} + +#[gpui::test] +async fn test_indent_guide_with_expanded_diff_hunks(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + let text = indoc! { + " + impl A { + fn b() { + 0; + 3; + 5; + 6; + 7; + } + } + " + }; + let base_text = indoc! { + " + impl A { + fn b() { + 0; + 1; + 2; + 3; + 4; + } + fn c() { + 5; + 6; + 7; + } + } + " + }; + + cx.update_editor(|editor, window, cx| { + editor.set_text(text, window, cx); + + editor.buffer().update(cx, |multibuffer, cx| { + let buffer = multibuffer.as_singleton().unwrap(); + let diff = cx.new(|cx| { + BufferDiff::new_with_base_text(base_text, &buffer.read(cx).text_snapshot(), cx) + }); + + multibuffer.set_all_diff_hunks_expanded(cx); + multibuffer.add_diff(diff, cx); + + buffer.read(cx).remote_id() + }) + }); + cx.run_until_parked(); + + cx.assert_state_with_diff( + indoc! { " + impl A { + fn b() { + 0; + - 1; + - 2; + 3; + - 4; + - } + - fn c() { + 5; + 6; + 7; + } + } + ˇ" + } + .to_string(), + ); + + let mut actual_guides = cx.update_editor(|editor, window, cx| { + editor + .snapshot(window, cx) + .buffer_snapshot() + .indent_guides_in_range(Anchor::Min..Anchor::Max, false, cx) + .map(|guide| (guide.start_row..=guide.end_row, guide.depth)) + .collect::>() + }); + actual_guides.sort_by_key(|item| (*item.0.start(), item.1)); + assert_eq!( + actual_guides, + vec![ + (MultiBufferRow(1)..=MultiBufferRow(12), 0), + (MultiBufferRow(2)..=MultiBufferRow(6), 1), + (MultiBufferRow(9)..=MultiBufferRow(11), 1), + ] + ); +} + +#[gpui::test] +async fn test_adjacent_diff_hunks(executor: BackgroundExecutor, cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + + let diff_base = r#" + a + b + c + "# + .unindent(); + + cx.set_state( + &r#" + ˇA + b + C + "# + .unindent(), + ); + cx.set_head_text(&diff_base); + cx.update_editor(|editor, window, cx| { + editor.expand_all_diff_hunks(&ExpandAllDiffHunks, window, cx); + }); + executor.run_until_parked(); + + let both_hunks_expanded = r#" + - a + + ˇA + b + - c + + C + "# + .unindent(); + + cx.assert_state_with_diff(both_hunks_expanded.clone()); + + let hunk_ranges = cx.update_editor(|editor, window, cx| { + let snapshot = editor.snapshot(window, cx); + let hunks = editor + .diff_hunks_in_ranges(&[Anchor::Min..Anchor::Max], &snapshot.buffer_snapshot()) + .collect::>(); + let multibuffer_snapshot = editor.buffer.read(cx).snapshot(cx); + hunks + .into_iter() + .map(|hunk| { + multibuffer_snapshot + .anchor_in_excerpt(hunk.buffer_range.start) + .unwrap() + ..multibuffer_snapshot + .anchor_in_excerpt(hunk.buffer_range.end) + .unwrap() + }) + .collect::>() + }); + assert_eq!(hunk_ranges.len(), 2); + + cx.update_editor(|editor, _, cx| { + editor.toggle_single_diff_hunk(hunk_ranges[0].clone(), cx); + }); + executor.run_until_parked(); + + let second_hunk_expanded = r#" + ˇA + b + - c + + C + "# + .unindent(); + + cx.assert_state_with_diff(second_hunk_expanded); + + cx.update_editor(|editor, _, cx| { + editor.toggle_single_diff_hunk(hunk_ranges[0].clone(), cx); + }); + executor.run_until_parked(); + + cx.assert_state_with_diff(both_hunks_expanded.clone()); + + cx.update_editor(|editor, _, cx| { + editor.toggle_single_diff_hunk(hunk_ranges[1].clone(), cx); + }); + executor.run_until_parked(); + + let first_hunk_expanded = r#" + - a + + ˇA + b + C + "# + .unindent(); + + cx.assert_state_with_diff(first_hunk_expanded); + + cx.update_editor(|editor, _, cx| { + editor.toggle_single_diff_hunk(hunk_ranges[1].clone(), cx); + }); + executor.run_until_parked(); + + cx.assert_state_with_diff(both_hunks_expanded); + + cx.set_state( + &r#" + ˇA + b + "# + .unindent(), + ); + cx.run_until_parked(); + + // TODO this cursor position seems bad + cx.assert_state_with_diff( + r#" + - ˇa + + A + b + "# + .unindent(), + ); + + cx.update_editor(|editor, window, cx| { + editor.expand_all_diff_hunks(&ExpandAllDiffHunks, window, cx); + }); + + cx.assert_state_with_diff( + r#" + - ˇa + + A + b + - c + "# + .unindent(), + ); + + let hunk_ranges = cx.update_editor(|editor, window, cx| { + let snapshot = editor.snapshot(window, cx); + let hunks = editor + .diff_hunks_in_ranges(&[Anchor::Min..Anchor::Max], &snapshot.buffer_snapshot()) + .collect::>(); + let multibuffer_snapshot = snapshot.buffer_snapshot(); + hunks + .into_iter() + .map(|hunk| { + multibuffer_snapshot + .anchor_in_excerpt(hunk.buffer_range.start) + .unwrap() + ..multibuffer_snapshot + .anchor_in_excerpt(hunk.buffer_range.end) + .unwrap() + }) + .collect::>() + }); + assert_eq!(hunk_ranges.len(), 2); + + cx.update_editor(|editor, _, cx| { + editor.toggle_single_diff_hunk(hunk_ranges[1].clone(), cx); + }); + executor.run_until_parked(); + + cx.assert_state_with_diff( + r#" + - ˇa + + A + b + "# + .unindent(), + ); +} + +#[gpui::test] +async fn test_toggle_deletion_hunk_at_start_of_file( + executor: BackgroundExecutor, + cx: &mut TestAppContext, +) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + + let diff_base = r#" + a + b + c + "# + .unindent(); + + cx.set_state( + &r#" + ˇb + c + "# + .unindent(), + ); + cx.set_head_text(&diff_base); + cx.update_editor(|editor, window, cx| { + editor.expand_all_diff_hunks(&ExpandAllDiffHunks, window, cx); + }); + executor.run_until_parked(); + + let hunk_expanded = r#" + - a + ˇb + c + "# + .unindent(); + + cx.assert_state_with_diff(hunk_expanded.clone()); + + let hunk_ranges = cx.update_editor(|editor, window, cx| { + let snapshot = editor.snapshot(window, cx); + let hunks = editor + .diff_hunks_in_ranges(&[Anchor::Min..Anchor::Max], &snapshot.buffer_snapshot()) + .collect::>(); + let multibuffer_snapshot = editor.buffer.read(cx).snapshot(cx); + hunks + .into_iter() + .map(|hunk| { + multibuffer_snapshot + .anchor_in_excerpt(hunk.buffer_range.start) + .unwrap() + ..multibuffer_snapshot + .anchor_in_excerpt(hunk.buffer_range.end) + .unwrap() + }) + .collect::>() + }); + assert_eq!(hunk_ranges.len(), 1); + + cx.update_editor(|editor, _, cx| { + editor.toggle_single_diff_hunk(hunk_ranges[0].clone(), cx); + }); + executor.run_until_parked(); + + let hunk_collapsed = r#" + ˇb + c + "# + .unindent(); + + cx.assert_state_with_diff(hunk_collapsed); + + cx.update_editor(|editor, _, cx| { + editor.toggle_single_diff_hunk(hunk_ranges[0].clone(), cx); + }); + executor.run_until_parked(); + + cx.assert_state_with_diff(hunk_expanded); +} + +#[gpui::test] +async fn test_select_smaller_syntax_node_after_diff_hunk_collapse( + executor: BackgroundExecutor, + cx: &mut TestAppContext, +) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + cx.update_buffer(|buffer, cx| buffer.set_language(Some(rust_lang()), cx)); + + cx.set_state( + &r#" + fn main() { + let x = ˇ1; + } + "# + .unindent(), + ); + + let diff_base = r#" + fn removed_one() { + println!("this function was deleted"); + } + + fn removed_two() { + println!("this function was also deleted"); + } + + fn main() { + let x = 1; + } + "# + .unindent(); + cx.set_head_text(&diff_base); + executor.run_until_parked(); + + cx.update_editor(|editor, window, cx| { + editor.expand_all_diff_hunks(&ExpandAllDiffHunks, window, cx); + }); + executor.run_until_parked(); + + cx.update_editor(|editor, window, cx| { + editor.select_larger_syntax_node(&SelectLargerSyntaxNode, window, cx); + }); + + cx.update_editor(|editor, window, cx| { + editor.collapse_all_diff_hunks(&CollapseAllDiffHunks, window, cx); + }); + executor.run_until_parked(); + + cx.update_editor(|editor, window, cx| { + editor.select_smaller_syntax_node(&SelectSmallerSyntaxNode, window, cx); + }); +} + +#[gpui::test] +async fn test_expand_first_line_diff_hunk_keeps_deleted_lines_visible( + executor: BackgroundExecutor, + cx: &mut TestAppContext, +) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + + cx.set_state("ˇnew\nsecond\nthird\n"); + cx.set_head_text("old\nsecond\nthird\n"); + cx.update_editor(|editor, window, cx| { + editor.scroll(gpui::Point { x: 0., y: 0. }, window, cx); + }); + executor.run_until_parked(); + assert_eq!(cx.update_editor(|e, _, cx| e.scroll_position(cx)).y, 0.0); + + // Expanding a diff hunk at the first line inserts deleted lines above the first buffer line. + cx.update_editor(|editor, window, cx| { + let snapshot = editor.snapshot(window, cx); + let multibuffer_snapshot = editor.buffer.read(cx).snapshot(cx); + let hunks = editor + .diff_hunks_in_ranges(&[Anchor::Min..Anchor::Max], &snapshot.buffer_snapshot()) + .collect::>(); + assert_eq!(hunks.len(), 1); + let hunk_range = multibuffer_snapshot + .anchor_in_excerpt(hunks[0].buffer_range.start) + .unwrap() + ..multibuffer_snapshot + .anchor_in_excerpt(hunks[0].buffer_range.end) + .unwrap(); + editor.toggle_single_diff_hunk(hunk_range, cx) + }); + executor.run_until_parked(); + cx.assert_state_with_diff("- old\n+ ˇnew\n second\n third\n".to_string()); + + // Keep the editor scrolled to the top so the full hunk remains visible. + assert_eq!(cx.update_editor(|e, _, cx| e.scroll_position(cx)).y, 0.0); +} + +#[gpui::test] +async fn test_display_diff_hunks(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/test"), + json!({ + ".git": {}, + "file-1": "ONE\n", + "file-2": "TWO\n", + "file-3": "THREE\n", + }), + ) + .await; + + fs.set_head_for_repo( + path!("/test/.git").as_ref(), + &[ + ("file-1", "one\n".into()), + ("file-2", "two\n".into()), + ("file-3", "three\n".into()), + ], + "deadbeef", + ); + + let project = Project::test(fs, [path!("/test").as_ref()], cx).await; + let mut buffers = vec![]; + for i in 1..=3 { + let buffer = project + .update(cx, |project, cx| { + let path = format!(path!("/test/file-{}"), i); + project.open_local_buffer(path, cx) + }) + .await + .unwrap(); + buffers.push(buffer); + } + + let multibuffer = cx.new(|cx| { + let mut multibuffer = MultiBuffer::new(Capability::ReadWrite); + multibuffer.set_all_diff_hunks_expanded(cx); + for buffer in &buffers { + let snapshot = buffer.read(cx).snapshot(); + multibuffer.set_excerpts_for_path( + PathKey::with_sort_prefix(0, buffer.read(cx).file().unwrap().path().clone()), + buffer.clone(), + vec![Point::zero()..snapshot.max_point()], + 2, + cx, + ); + } + multibuffer + }); + + let editor = cx.add_window(|window, cx| { + Editor::new(EditorMode::full(), multibuffer, Some(project), window, cx) + }); + cx.run_until_parked(); + + let snapshot = editor + .update(cx, |editor, window, cx| editor.snapshot(window, cx)) + .unwrap(); + let hunks = snapshot + .display_diff_hunks_for_rows(DisplayRow(0)..DisplayRow(u32::MAX), &Default::default()) + .map(|hunk| match hunk { + DisplayDiffHunk::Unfolded { + display_row_range, .. + } => display_row_range, + DisplayDiffHunk::Folded { .. } => unreachable!(), + }) + .collect::>(); + assert_eq!( + hunks, + [ + DisplayRow(2)..DisplayRow(4), + DisplayRow(7)..DisplayRow(9), + DisplayRow(12)..DisplayRow(14), + ] + ); +} + +#[gpui::test] +async fn test_partially_staged_hunk(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + cx.set_head_text(indoc! { " + one + two + three + four + five + " + }); + cx.set_index_text(indoc! { " + one + two + three + four + five + " + }); + cx.set_state(indoc! {" + one + TWO + ˇTHREE + FOUR + five + "}); + cx.run_until_parked(); + cx.update_editor(|editor, window, cx| { + editor.toggle_staged_selected_diff_hunks(&Default::default(), window, cx); + }); + cx.run_until_parked(); + cx.assert_index_text(Some(indoc! {" + one + TWO + THREE + FOUR + five + "})); + cx.set_state(indoc! { " + one + TWO + ˇTHREE-HUNDRED + FOUR + five + "}); + cx.run_until_parked(); + cx.update_editor(|editor, window, cx| { + let snapshot = editor.snapshot(window, cx); + let hunks = editor + .diff_hunks_in_ranges(&[Anchor::Min..Anchor::Max], &snapshot.buffer_snapshot()) + .collect::>(); + assert_eq!(hunks.len(), 1); + assert_eq!( + hunks[0].status(), + DiffHunkStatus { + kind: DiffHunkStatusKind::Modified, + secondary: DiffHunkSecondaryStatus::OverlapsWithSecondaryHunk + } + ); + + editor.toggle_staged_selected_diff_hunks(&Default::default(), window, cx); + }); + cx.run_until_parked(); + cx.assert_index_text(Some(indoc! {" + one + TWO + THREE-HUNDRED + FOUR + five + "})); +} + +#[gpui::test] +fn test_crease_insertion_and_rendering(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let editor = cx.add_window(|window, cx| { + let buffer = MultiBuffer::build_simple("aaaaaa\nbbbbbb\ncccccc\nddddddd\n", cx); + build_editor(buffer, window, cx) + }); + + let render_args = Arc::new(Mutex::new(None)); + let snapshot = editor + .update(cx, |editor, window, cx| { + let snapshot = editor.buffer().read(cx).snapshot(cx); + let range = + snapshot.anchor_before(Point::new(1, 0))..snapshot.anchor_after(Point::new(2, 6)); + + struct RenderArgs { + row: MultiBufferRow, + folded: bool, + callback: Arc, + } + + let crease = Crease::inline( + range, + FoldPlaceholder::test(), + { + let toggle_callback = render_args.clone(); + move |row, folded, callback, _window, _cx| { + *toggle_callback.lock() = Some(RenderArgs { + row, + folded, + callback, + }); + div() + } + }, + |_row, _folded, _window, _cx| div(), + ); + + editor.insert_creases(Some(crease), cx); + let snapshot = editor.snapshot(window, cx); + let _div = + snapshot.render_crease_toggle(MultiBufferRow(1), false, cx.entity(), window, cx); + snapshot + }) + .unwrap(); + + let render_args = render_args.lock().take().unwrap(); + assert_eq!(render_args.row, MultiBufferRow(1)); + assert!(!render_args.folded); + assert!(!snapshot.is_line_folded(MultiBufferRow(1))); + + cx.update_window(*editor, |_, window, cx| { + (render_args.callback)(true, window, cx) + }) + .unwrap(); + let snapshot = editor + .update(cx, |editor, window, cx| editor.snapshot(window, cx)) + .unwrap(); + assert!(snapshot.is_line_folded(MultiBufferRow(1))); + + cx.update_window(*editor, |_, window, cx| { + (render_args.callback)(false, window, cx) + }) + .unwrap(); + let snapshot = editor + .update(cx, |editor, window, cx| editor.snapshot(window, cx)) + .unwrap(); + assert!(!snapshot.is_line_folded(MultiBufferRow(1))); +} + +#[gpui::test] +async fn test_input_text(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + + cx.set_state( + &r#"ˇone + two + + three + fourˇ + five + + siˇx"# + .unindent(), + ); + + cx.dispatch_action(HandleInput(String::new())); + cx.assert_editor_state( + &r#"ˇone + two + + three + fourˇ + five + + siˇx"# + .unindent(), + ); + + cx.dispatch_action(HandleInput("AAAA".to_string())); + cx.assert_editor_state( + &r#"AAAAˇone + two + + three + fourAAAAˇ + five + + siAAAAˇx"# + .unindent(), + ); +} + +#[gpui::test] +async fn test_scroll_cursor_center_top_bottom(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + cx.set_state( + r#"let foo = 1; +let foo = 2; +let foo = 3; +let fooˇ = 4; +let foo = 5; +let foo = 6; +let foo = 7; +let foo = 8; +let foo = 9; +let foo = 10; +let foo = 11; +let foo = 12; +let foo = 13; +let foo = 14; +let foo = 15;"#, + ); + + cx.update_editor(|e, window, cx| { + assert_eq!( + e.next_scroll_position, + NextScrollCursorCenterTopBottom::Center, + "Default next scroll direction is center", + ); + + e.scroll_cursor_center_top_bottom(&ScrollCursorCenterTopBottom, window, cx); + assert_eq!( + e.next_scroll_position, + NextScrollCursorCenterTopBottom::Top, + "After center, next scroll direction should be top", + ); + + e.scroll_cursor_center_top_bottom(&ScrollCursorCenterTopBottom, window, cx); + assert_eq!( + e.next_scroll_position, + NextScrollCursorCenterTopBottom::Bottom, + "After top, next scroll direction should be bottom", + ); + + e.scroll_cursor_center_top_bottom(&ScrollCursorCenterTopBottom, window, cx); + assert_eq!( + e.next_scroll_position, + NextScrollCursorCenterTopBottom::Center, + "After bottom, scrolling should start over", + ); + + e.scroll_cursor_center_top_bottom(&ScrollCursorCenterTopBottom, window, cx); + assert_eq!( + e.next_scroll_position, + NextScrollCursorCenterTopBottom::Top, + "Scrolling continues if retriggered fast enough" + ); + }); + + cx.executor() + .advance_clock(SCROLL_CENTER_TOP_BOTTOM_DEBOUNCE_TIMEOUT + Duration::from_millis(200)); + cx.executor().run_until_parked(); + cx.update_editor(|e, _, _| { + assert_eq!( + e.next_scroll_position, + NextScrollCursorCenterTopBottom::Center, + "If scrolling is not triggered fast enough, it should reset" + ); + }); +} + +#[gpui::test] +async fn test_goto_definition_reveals_existing_editor_in_other_pane(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + cx.update(|cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings.workspace.reveal_if_open = Some(true); + }); + }); + }); + + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + definition_provider: Some(lsp::OneOf::Left(true)), + ..lsp::ServerCapabilities::default() + }, + cx, + ) + .await; + cx.set_state("fn main() { ˇtarget(); }"); + + let origin_path = cx.update_editor(|editor, _, cx| editor.active_project_path(cx).unwrap()); + let (fake_fs, worktree_id) = cx.update_workspace(|workspace, _, cx| { + let project = workspace.project(); + let fake_fs = project.read(cx).fs().as_fake(); + let worktree_id = project.read(cx).worktrees(cx).next().unwrap().read(cx).id(); + (fake_fs, worktree_id) + }); + let target_abs_path = EditorLspTestContext::root_path() + .join("dir") + .join("definition.rs"); + fake_fs + .insert_file(&target_abs_path, b"fn target() {}\n".to_vec()) + .await; + cx.run_until_parked(); + + let (left_pane, right_pane) = cx.update_workspace(|workspace, window, cx| { + let left_pane = workspace.active_pane().clone(); + let right_pane = workspace.split_pane(left_pane.clone(), SplitDirection::Right, window, cx); + (left_pane, right_pane) + }); + let origin_editor = cx + .update_workspace(|workspace, window, cx| { + workspace.open_path(origin_path, Some(right_pane.downgrade()), true, window, cx) + }) + .await + .unwrap() + .downcast::() + .unwrap(); + let target_editor = cx + .update_workspace(|workspace, window, cx| { + workspace.open_path( + (worktree_id, rel_path("dir/definition.rs")), + Some(left_pane.downgrade()), + false, + window, + cx, + ) + }) + .await + .unwrap() + .downcast::() + .unwrap(); + let target_buffer = target_editor.read_with(&cx.cx.cx, |editor, cx| { + editor.buffer().read(cx).as_singleton().unwrap() + }); + cx.update_workspace(|workspace, window, cx| { + assert!(workspace.activate_item(&origin_editor, true, true, window, cx)); + }); + cx.run_until_parked(); + cx.update_workspace(|workspace, _, _| { + assert_eq!(workspace.active_pane(), &right_pane); + }); + + let target_uri = lsp::Uri::from_file_path(target_abs_path).unwrap(); + let _go_to_definition = cx + .lsp + .set_request_handler::(move |_, _| { + let target_uri = target_uri.clone(); + async move { + Ok(Some(lsp::GotoDefinitionResponse::Scalar(lsp::Location { + uri: target_uri, + range: lsp::Range::new(lsp::Position::new(0, 3), lsp::Position::new(0, 9)), + }))) + } + }); + + let navigated = origin_editor + .update_in(&mut cx.cx.cx, |editor, window, cx| { + editor.go_to_definition(&GoToDefinition::default(), window, cx) + }) + .await + .expect("Failed to navigate to definition"); + assert_eq!(navigated, Navigated::Yes); + cx.run_until_parked(); + + cx.update_workspace(|workspace, _, cx| { + assert_eq!(workspace.active_pane(), &left_pane); + assert_eq!( + left_pane + .read(cx) + .active_item() + .unwrap() + .downcast::() + .unwrap(), + target_editor + ); + assert_eq!( + right_pane + .read(cx) + .active_item() + .unwrap() + .downcast::() + .unwrap(), + origin_editor + ); + assert_eq!( + workspace + .items_of_type::(cx) + .filter(|editor| { + editor.read(cx).buffer().read(cx).as_singleton().as_ref() + == Some(&target_buffer) + }) + .count(), + 1 + ); + }); +} + +#[gpui::test] +async fn test_goto_definition_with_find_all_references_fallback(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + definition_provider: Some(lsp::OneOf::Left(true)), + references_provider: Some(lsp::OneOf::Left(true)), + ..lsp::ServerCapabilities::default() + }, + cx, + ) + .await; + + let set_up_lsp_handlers = |empty_go_to_definition: bool, cx: &mut EditorLspTestContext| { + let go_to_definition = cx + .lsp + .set_request_handler::( + move |params, _| async move { + if empty_go_to_definition { + Ok(None) + } else { + Ok(Some(lsp::GotoDefinitionResponse::Scalar(lsp::Location { + uri: params.text_document_position_params.text_document.uri, + range: lsp::Range::new( + lsp::Position::new(4, 3), + lsp::Position::new(4, 6), + ), + }))) + } + }, + ); + let references = cx + .lsp + .set_request_handler::(move |params, _| async move { + Ok(Some(vec![lsp::Location { + uri: params.text_document_position.text_document.uri, + range: lsp::Range::new(lsp::Position::new(0, 8), lsp::Position::new(0, 11)), + }])) + }); + (go_to_definition, references) + }; + + cx.set_state( + &r#"fn one() { + let mut a = ˇtwo(); + } + + fn two() {}"# + .unindent(), + ); + set_up_lsp_handlers(false, &mut cx); + let navigated = cx + .update_editor(|editor, window, cx| { + editor.go_to_definition(&GoToDefinition::default(), window, cx) + }) + .await + .expect("Failed to navigate to definition"); + assert_eq!( + navigated, + Navigated::Yes, + "Should have navigated to definition from the GetDefinition response" + ); + cx.assert_editor_state( + &r#"fn one() { + let mut a = two(); + } + + fn «twoˇ»() {}"# + .unindent(), + ); + + let editors = cx.update_workspace(|workspace, _, cx| { + workspace.items_of_type::(cx).collect::>() + }); + cx.update_editor(|_, _, test_editor_cx| { + assert_eq!( + editors.len(), + 1, + "Initially, only one, test, editor should be open in the workspace" + ); + assert_eq!( + test_editor_cx.entity(), + editors.last().expect("Asserted len is 1").clone() + ); + }); + + set_up_lsp_handlers(true, &mut cx); + let navigated = cx + .update_editor(|editor, window, cx| { + editor.go_to_definition(&GoToDefinition::default(), window, cx) + }) + .await + .expect("Failed to navigate to lookup references"); + assert_eq!( + navigated, + Navigated::Yes, + "Should have navigated to references as a fallback after empty GoToDefinition response" + ); + // We should not change the selections in the existing file, + // if opening another milti buffer with the references + cx.assert_editor_state( + &r#"fn one() { + let mut a = two(); + } + + fn «twoˇ»() {}"# + .unindent(), + ); + let editors = cx.update_workspace(|workspace, _, cx| { + workspace.items_of_type::(cx).collect::>() + }); + cx.update_editor(|_, _, test_editor_cx| { + assert_eq!( + editors.len(), + 2, + "After falling back to references search, we open a new editor with the results" + ); + let references_fallback_text = editors + .into_iter() + .find(|new_editor| *new_editor != test_editor_cx.entity()) + .expect("Should have one non-test editor now") + .read(test_editor_cx) + .text(test_editor_cx); + assert_eq!( + references_fallback_text, "fn one() {\n let mut a = two();\n}", + "Should use the range from the references response and not the GoToDefinition one" + ); + }); +} + +/// End-to-end regression test for ZED-79W ("cannot summarize backward"): +/// language servers can return a location whose range is reversed (start +/// after end). `GetReferences::response_from_lsp` used to convert the +/// endpoints individually, bypassing `range_from_lsp`'s normalization, so +/// the reversed range reached excerpt construction intact, and when the +/// reversal exceeded twice the excerpt context line count, the results +/// multibuffer built an excerpt whose context anchors resolve backward, +/// panicking the rope layer. +#[gpui::test] +async fn test_find_all_references_with_reversed_server_range(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + references_provider: Some(lsp::OneOf::Left(true)), + ..lsp::ServerCapabilities::default() + }, + cx, + ) + .await; + + cx.set_state( + &r#"fn one() { + let mut a = ˇtwo(); + } + + fn two() {} + + fn three() { + two(); + two(); + two(); + }"# + .unindent(), + ); + cx.lsp + .set_request_handler::(move |params, _| async move { + Ok(Some(vec![ + lsp::Location { + uri: params.text_document_position.text_document.uri.clone(), + range: lsp::Range::new(lsp::Position::new(1, 16), lsp::Position::new(1, 19)), + }, + // A reversed range, as returned by some language servers. + lsp::Location { + uri: params.text_document_position.text_document.uri, + range: lsp::Range::new(lsp::Position::new(9, 8), lsp::Position::new(1, 16)), + }, + ])) + }); + + let navigated = cx + .update_editor(|editor, window, cx| { + editor.find_all_references(&FindAllReferences::default(), window, cx) + }) + .expect("should have spawned a references request") + .await + .expect("references request should succeed"); + assert_eq!(navigated, Navigated::Yes); + + let editors = cx.update_workspace(|workspace, _, cx| { + workspace.items_of_type::(cx).collect::>() + }); + cx.update_editor(|_, _, test_editor_cx| { + assert_eq!( + editors.len(), + 2, + "references should open in a new multibuffer editor" + ); + let references_text = editors + .into_iter() + .find(|new_editor| *new_editor != test_editor_cx.entity()) + .expect("should have one non-test editor") + .read(test_editor_cx) + .text(test_editor_cx); + assert!( + references_text.contains("fn three()"), + "the reversed range's rows should be excerpted, got: {references_text:?}" + ); + }); +} + +#[gpui::test] +async fn test_goto_definition_no_fallback(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + cx.update(|cx| { + let mut editor_settings = EditorSettings::get_global(cx).clone(); + editor_settings.go_to_definition_fallback = GoToDefinitionFallback::None; + EditorSettings::override_global(editor_settings, cx); + }); + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + definition_provider: Some(lsp::OneOf::Left(true)), + references_provider: Some(lsp::OneOf::Left(true)), + ..lsp::ServerCapabilities::default() + }, + cx, + ) + .await; + let original_state = r#"fn one() { + let mut a = ˇtwo(); + } + + fn two() {}"# + .unindent(); + cx.set_state(&original_state); + + let mut go_to_definition = cx + .lsp + .set_request_handler::( + move |_, _| async move { Ok(None) }, + ); + let _references = cx + .lsp + .set_request_handler::(move |_, _| async move { + panic!("Should not call for references with no go to definition fallback") + }); + + let navigated = cx + .update_editor(|editor, window, cx| { + editor.go_to_definition(&GoToDefinition::default(), window, cx) + }) + .await + .expect("Failed to navigate to lookup references"); + go_to_definition + .next() + .await + .expect("Should have called the go_to_definition handler"); + + assert_eq!( + navigated, + Navigated::No, + "Should have navigated to references as a fallback after empty GoToDefinition response" + ); + cx.assert_editor_state(&original_state); + let editors = cx.update_workspace(|workspace, _, cx| { + workspace.items_of_type::(cx).collect::>() + }); + cx.update_editor(|_, _, _| { + assert_eq!( + editors.len(), + 1, + "After unsuccessful fallback, no other editor should have been opened" + ); + }); +} + +#[gpui::test] +async fn test_goto_definition_close_ranges_open_singleton(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + definition_provider: Some(lsp::OneOf::Left(true)), + ..lsp::ServerCapabilities::default() + }, + cx, + ) + .await; + + // File content: 10 lines with functions defined on lines 3, 5, and 7 (0-indexed). + // With the default excerpt_context_lines of 2, ranges that are within + // 2 * 2 = 4 rows of each other should be grouped into one excerpt. + cx.set_state( + &r#"fn caller() { + let _ = ˇtarget(); + } + fn target_a() {} + + fn target_b() {} + + fn target_c() {} + "# + .unindent(), + ); + + // Return two definitions that are close together (lines 3 and 5, gap of 2 rows) + cx.set_request_handler::(move |url, _, _| async move { + Ok(Some(lsp::GotoDefinitionResponse::Array(vec![ + lsp::Location { + uri: url.clone(), + range: lsp::Range::new(lsp::Position::new(3, 3), lsp::Position::new(3, 11)), + }, + lsp::Location { + uri: url, + range: lsp::Range::new(lsp::Position::new(5, 3), lsp::Position::new(5, 11)), + }, + ]))) + }); + + let navigated = cx + .update_editor(|editor, window, cx| { + editor.go_to_definition(&GoToDefinition::default(), window, cx) + }) + .await + .expect("Failed to navigate to definitions"); + assert_eq!(navigated, Navigated::Yes); + + let editors = cx.update_workspace(|workspace, _, cx| { + workspace.items_of_type::(cx).collect::>() + }); + cx.update_editor(|_, _, _| { + assert_eq!( + editors.len(), + 1, + "Close ranges should navigate in-place without opening a new editor" + ); + }); + + // Both target ranges should be selected + cx.assert_editor_state( + &r#"fn caller() { + let _ = target(); + } + fn «target_aˇ»() {} + + fn «target_bˇ»() {} + + fn target_c() {} + "# + .unindent(), + ); +} + +#[gpui::test] +async fn test_goto_definition_far_ranges_open_multibuffer(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + definition_provider: Some(lsp::OneOf::Left(true)), + ..lsp::ServerCapabilities::default() + }, + cx, + ) + .await; + + // Create a file with definitions far apart (more than 2 * excerpt_context_lines rows). + cx.set_state( + &r#"fn caller() { + let _ = ˇtarget(); + } + fn target_a() {} + + + + + + + + + + + + + + + + fn target_b() {} + "# + .unindent(), + ); + + // Return two definitions that are far apart (lines 3 and 19, gap of 16 rows) + cx.set_request_handler::(move |url, _, _| async move { + Ok(Some(lsp::GotoDefinitionResponse::Array(vec![ + lsp::Location { + uri: url.clone(), + range: lsp::Range::new(lsp::Position::new(3, 3), lsp::Position::new(3, 11)), + }, + lsp::Location { + uri: url, + range: lsp::Range::new(lsp::Position::new(19, 3), lsp::Position::new(19, 11)), + }, + ]))) + }); + + let navigated = cx + .update_editor(|editor, window, cx| { + editor.go_to_definition(&GoToDefinition::default(), window, cx) + }) + .await + .expect("Failed to navigate to definitions"); + assert_eq!(navigated, Navigated::Yes); + + let editors = cx.update_workspace(|workspace, _, cx| { + workspace.items_of_type::(cx).collect::>() + }); + cx.update_editor(|_, _, test_editor_cx| { + assert_eq!( + editors.len(), + 2, + "Far apart ranges should open a new multibuffer editor" + ); + let multibuffer_editor = editors + .into_iter() + .find(|editor| *editor != test_editor_cx.entity()) + .expect("Should have a multibuffer editor"); + let multibuffer_text = multibuffer_editor.read(test_editor_cx).text(test_editor_cx); + assert!( + multibuffer_text.contains("target_a"), + "Multibuffer should contain the first definition" + ); + assert!( + multibuffer_text.contains("target_b"), + "Multibuffer should contain the second definition" + ); + }); +} + +#[gpui::test] +async fn test_goto_definition_contained_ranges(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + definition_provider: Some(lsp::OneOf::Left(true)), + ..lsp::ServerCapabilities::default() + }, + cx, + ) + .await; + + // The LSP returns two single-line definitions on the same row where one + // range contains the other. Both are on the same line so the + // `fits_in_one_excerpt` check won't underflow, and the code reaches + // `change_selections`. + cx.set_state( + &r#"fn caller() { + let _ = ˇtarget(); + } + fn target_outer() { fn target_inner() {} } + "# + .unindent(), + ); + + // Return two definitions on the same line: an outer range covering the + // whole line and an inner range for just the inner function name. + cx.set_request_handler::(move |url, _, _| async move { + Ok(Some(lsp::GotoDefinitionResponse::Array(vec![ + // Inner range: just "target_inner" (cols 23..35) + lsp::Location { + uri: url.clone(), + range: lsp::Range::new(lsp::Position::new(3, 23), lsp::Position::new(3, 35)), + }, + // Outer range: the whole line (cols 0..48) + lsp::Location { + uri: url, + range: lsp::Range::new(lsp::Position::new(3, 0), lsp::Position::new(3, 48)), + }, + ]))) + }); + + let navigated = cx + .update_editor(|editor, window, cx| { + editor.go_to_definition(&GoToDefinition::default(), window, cx) + }) + .await + .expect("Failed to navigate to definitions"); + assert_eq!(navigated, Navigated::Yes); +} + +#[gpui::test] +async fn test_goto_definition_preserve_scroll_strategy(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + update_test_editor_settings(cx, &|settings| { + settings.go_to_definition_scroll_strategy = Some(GoToDefinitionScrollStrategy::Preserve); + settings.vertical_scroll_margin = Some(0.0); + }); + + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + definition_provider: Some(lsp::OneOf::Left(true)), + ..lsp::ServerCapabilities::default() + }, + cx, + ) + .await; + + let window = cx.window; + let line_height = cx.update_editor(|editor, window, cx| { + editor + .style(cx) + .text + .line_height_in_pixels(window.rem_size()) + }); + cx.simulate_window_resize(window, size(px(1000.), 8. * line_height)); + + // Build a buffer where `target` is defined on row 10 and called from + // row 20, with the cursor placed on the call site. + let buffer = indoc! { " + // 0 + // 1 + // 2 + // 3 + // 4 + // 5 + // 6 + // 7 + // 8 + // 9 + fn target() // 10 + // 11 + // 12 + // 13 + // 14 + // 15 + // 16 + // 17 + // 18 + // 19 + fn caller() { ˇtarget(); } // 20 + // 21 + // 22 + // 23 + // 24 + // 25 + // 26 + // 27 + // 28 + // 29 + // 30 + "}; + + // Mock the response from the LSP server when requesting to go to a + // definition so as to always jump to the `target` function. + cx.set_request_handler::(|url, _, _| async move { + Ok(Some(lsp::GotoDefinitionResponse::Scalar(lsp::Location { + uri: url.clone(), + range: lsp::Range::new(lsp::Position::new(10, 3), lsp::Position::new(10, 9)), + }))) + }); + + let caller_row = 20.0; + let target_row = 10.0; + let offset = 1.5; + let center_offset = cx.update_editor(|editor, _, _| { + editor + .visible_line_count() + .map(|count| ((count - 1.0) / 2.0).floor()) + .expect("Visible line count should be available") + }); + + // When the cursor is visible inside the viewport, going to a definition + // should preserve that same offset value. + // In this case, with the cursor set at row 20 and the scroll position set + // to 18.5 (20 - 1.5), when going to the definition of `target` in row 10, + // the scroll position should end up at 8.5 (10 - 1.5), so as to preserve + // that same offset of 1.5. + cx.set_state(&buffer); + cx.update_editor(|editor, window, cx| { + editor.set_scroll_position(gpui::Point::new(0.0, caller_row - offset), window, cx); + }); + cx.update_editor(|editor, window, cx| { + editor.go_to_definition(&GoToDefinition::default(), window, cx) + }) + .await + .expect("Failed to navigate to definition"); + cx.run_until_parked(); + cx.update_editor(|editor, window, cx| { + assert_eq!( + editor.snapshot(window, cx).scroll_position(), + gpui::Point::new(0.0, target_row - offset), + ); + }); + + // In the case where the cursor ends up outside of the visible viewport, the + // scroll position's offset should be ignored and the center of the viewport + // should be used instead. + // Since the cursor is jumping to row 10, the scroll position's y coordinate + // should end up at 10 minus the offset from the center of the viewport. + cx.set_state(&buffer); + cx.update_editor(|editor, window, cx| { + editor.set_scroll_position(gpui::Point::new(0.0, 0.0), window, cx); + let snapshot = editor.display_snapshot(cx); + let cursor_row = editor + .selections + .newest_display(&snapshot) + .start + .row() + .as_f64(); + let visible_lines = editor + .visible_line_count() + .expect("Visible line count should be available"); + + assert!(cursor_row >= visible_lines, "Cursor should be offscreen"); + }); + + cx.update_editor(|editor, window, cx| { + editor.go_to_definition(&GoToDefinition::default(), window, cx) + }) + .await + .expect("Failed to navigate to definition"); + cx.run_until_parked(); + cx.update_editor(|editor, window, cx| { + assert_eq!( + editor.snapshot(window, cx).scroll_position(), + gpui::Point::new(0.0, (target_row - center_offset).max(0.0)), + ); + }); +} + +#[gpui::test] +async fn test_find_all_references_editor_reuse(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + references_provider: Some(lsp::OneOf::Left(true)), + ..lsp::ServerCapabilities::default() + }, + cx, + ) + .await; + + cx.set_state( + &r#" + fn one() { + let mut a = two(); + } + + fn ˇtwo() {}"# + .unindent(), + ); + cx.lsp + .set_request_handler::(move |params, _| async move { + Ok(Some(vec![ + lsp::Location { + uri: params.text_document_position.text_document.uri.clone(), + range: lsp::Range::new(lsp::Position::new(0, 16), lsp::Position::new(0, 19)), + }, + lsp::Location { + uri: params.text_document_position.text_document.uri, + range: lsp::Range::new(lsp::Position::new(4, 4), lsp::Position::new(4, 7)), + }, + ])) + }); + let navigated = cx + .update_editor(|editor, window, cx| { + editor.find_all_references(&FindAllReferences::default(), window, cx) + }) + .unwrap() + .await + .expect("Failed to navigate to references"); + assert_eq!( + navigated, + Navigated::Yes, + "Should have navigated to references from the FindAllReferences response" + ); + cx.assert_editor_state( + &r#"fn one() { + let mut a = two(); + } + + fn ˇtwo() {}"# + .unindent(), + ); + + let editors = cx.update_workspace(|workspace, _, cx| { + workspace.items_of_type::(cx).collect::>() + }); + cx.update_editor(|_, _, _| { + assert_eq!(editors.len(), 2, "We should have opened a new multibuffer"); + }); + + cx.set_state( + &r#"fn one() { + let mut a = ˇtwo(); + } + + fn two() {}"# + .unindent(), + ); + let navigated = cx + .update_editor(|editor, window, cx| { + editor.find_all_references(&FindAllReferences::default(), window, cx) + }) + .unwrap() + .await + .expect("Failed to navigate to references"); + assert_eq!( + navigated, + Navigated::Yes, + "Should have navigated to references from the FindAllReferences response" + ); + cx.assert_editor_state( + &r#"fn one() { + let mut a = ˇtwo(); + } + + fn two() {}"# + .unindent(), + ); + let editors = cx.update_workspace(|workspace, _, cx| { + workspace.items_of_type::(cx).collect::>() + }); + cx.update_editor(|_, _, _| { + assert_eq!( + editors.len(), + 2, + "should have re-used the previous multibuffer" + ); + }); + + cx.set_state( + &r#"fn one() { + let mut a = ˇtwo(); + } + fn three() {} + fn two() {}"# + .unindent(), + ); + cx.lsp + .set_request_handler::(move |params, _| async move { + Ok(Some(vec![ + lsp::Location { + uri: params.text_document_position.text_document.uri.clone(), + range: lsp::Range::new(lsp::Position::new(0, 16), lsp::Position::new(0, 19)), + }, + lsp::Location { + uri: params.text_document_position.text_document.uri, + range: lsp::Range::new(lsp::Position::new(5, 4), lsp::Position::new(5, 7)), + }, + ])) + }); + let navigated = cx + .update_editor(|editor, window, cx| { + editor.find_all_references(&FindAllReferences::default(), window, cx) + }) + .unwrap() + .await + .expect("Failed to navigate to references"); + assert_eq!( + navigated, + Navigated::Yes, + "Should have navigated to references from the FindAllReferences response" + ); + cx.assert_editor_state( + &r#"fn one() { + let mut a = ˇtwo(); + } + fn three() {} + fn two() {}"# + .unindent(), + ); + let editors = cx.update_workspace(|workspace, _, cx| { + workspace.items_of_type::(cx).collect::>() + }); + cx.update_editor(|_, _, _| { + assert_eq!( + editors.len(), + 3, + "should have used a new multibuffer as offsets changed" + ); + }); +} + +#[gpui::test] +async fn test_find_all_references_preserves_preview_tab(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + cx.update(|cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings: &mut SettingsContent| { + let preview_tabs = settings.preview_tabs.get_or_insert_default(); + preview_tabs.enabled = Some(true); + preview_tabs.enable_preview_multibuffer_from_code_navigation = Some(true); + }); + }); + }); + + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + references_provider: Some(lsp::OneOf::Left(true)), + ..lsp::ServerCapabilities::default() + }, + cx, + ) + .await; + + // Calling `set_state` here will make it such that the underlying buffer + // will now report edits since its `preview_version` field, which is set + // when the buffer is first created. That is one of the conditions that + // determines whether a pane's item should remain as the preview item, or + // not, as such, if that field is not updated accordingly, we'd end up not + // having a preview item at the end of the test. + cx.set_state( + &r#" + fn one() { + let mut a = two(); + } + + fn ˇtwo() {}"# + .unindent(), + ); + cx.executor().run_until_parked(); + + cx.lsp + .set_request_handler::(move |params, _| async move { + Ok(Some(vec![ + lsp::Location { + uri: params.text_document_position.text_document.uri.clone(), + range: lsp::Range::new(lsp::Position::new(0, 16), lsp::Position::new(0, 19)), + }, + lsp::Location { + uri: params.text_document_position.text_document.uri, + range: lsp::Range::new(lsp::Position::new(4, 4), lsp::Position::new(4, 7)), + }, + ])) + }); + + // Using the `editor: find all references` action adds excerpts to the + // multibuffer, which emits `ItemEvent::Edit`. When the pane handles that + // event it decides whether to keep its preview item and, if the buffer + // reports any edits since its `preview_version`, the item is unpreviewed. + // We also need to run until the executor is parked because the + // `ItemEvent::Edit` is delivered later, after the item has been registered + // with the pane. + assert_eq!( + cx.update_editor(|editor, window, cx| { + editor.find_all_references(&FindAllReferences::default(), window, cx) + }) + .expect("Editor::find_all_references should return a task") + .await + .expect("Should be able to find all references"), + Navigated::Yes + ); + cx.executor().run_until_parked(); + + cx.update_workspace(|workspace, _window, cx| { + let active_pane = workspace.active_pane().read(cx); + + assert_eq!( + active_pane.preview_item_id(), + Some(active_pane.active_item().unwrap().item_id()), + "The preview item id should be the same as the id of the multibuffer" + ); + }); +} + +#[gpui::test] +async fn test_find_enclosing_node_with_task(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let language = Arc::new(Language::new( + LanguageConfig::default(), + Some(tree_sitter_rust::LANGUAGE.into()), + )); + + let text = r#" + #[cfg(test)] + mod tests() { + #[test] + fn runnable_1() { + let a = 1; + } + + #[test] + fn runnable_2() { + let a = 1; + let b = 2; + } + } + "# + .unindent(); + + let fs = FakeFs::new(cx.executor()); + fs.insert_file("/file.rs", Default::default()).await; + + let project = Project::test(fs, ["/a".as_ref()], cx).await; + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(*window, cx); + let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(language, cx)); + let multi_buffer = cx.new(|cx| MultiBuffer::singleton(buffer.clone(), cx)); + + let editor = cx.new_window_entity(|window, cx| { + Editor::new( + EditorMode::full(), + multi_buffer, + Some(project.clone()), + window, + cx, + ) + }); + + editor.update_in(cx, |editor, window, cx| { + let snapshot = editor.buffer().read(cx).snapshot(cx); + editor.runnables.insert( + buffer.read(cx).remote_id(), + 3, + buffer.read(cx).version(), + RunnableTasks { + templates: Vec::new(), + offset: snapshot.anchor_before(MultiBufferOffset(43)), + column: 0, + extra_variables: HashMap::default(), + context_range: BufferOffset(43)..BufferOffset(85), + }, + ); + editor.runnables.insert( + buffer.read(cx).remote_id(), + 8, + buffer.read(cx).version(), + RunnableTasks { + templates: Vec::new(), + offset: snapshot.anchor_before(MultiBufferOffset(86)), + column: 0, + extra_variables: HashMap::default(), + context_range: BufferOffset(86)..BufferOffset(191), + }, + ); + + // Test finding task when cursor is inside function body + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_ranges([Point::new(4, 5)..Point::new(4, 5)]) + }); + let (_, row, _) = editor.find_enclosing_node_task(cx).unwrap(); + assert_eq!(row, 3, "Should find task for cursor inside runnable_1"); + + // Test finding task when cursor is on function name + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_ranges([Point::new(8, 4)..Point::new(8, 4)]) + }); + let (_, row, _) = editor.find_enclosing_node_task(cx).unwrap(); + assert_eq!(row, 8, "Should find task when cursor is on function name"); + }); +} + +#[gpui::test] +async fn test_toggle_code_actions_build_tasks_context_error_notifies(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + struct FailingContextProvider; + impl ContextProvider for FailingContextProvider { + fn build_context( + &self, + _: &TaskVariables, + _: ContextLocation<'_>, + _: Option>, + _: Arc, + _: &mut gpui::App, + ) -> Task> { + Task::ready(Err(anyhow::anyhow!("Task context provider failed"))) + } + } + + let language = Arc::new( + Arc::try_unwrap(rust_lang()) + .unwrap() + .with_context_provider(Some(Arc::new(FailingContextProvider))), + ); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/a"), json!({ "main.rs": "fn main() {}" })) + .await; + + let project = Project::test(fs, [path!("/a").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(language.clone()); + + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let mut cx = VisualTestContext::from_window(*window, cx); + let workspace = window + .read_with(&mut cx, |mw, _| mw.workspace().clone()) + .unwrap(); + + let worktree_id = workspace.update_in(&mut cx, |workspace, _, cx| { + workspace.project().update(cx, |project, cx| { + project.worktrees(cx).next().unwrap().read(cx).id() + }) + }); + + let editor = workspace + .update_in(&mut cx, |workspace, window, cx| { + workspace.open_path((worktree_id, rel_path("main.rs")), None, true, window, cx) + }) + .await + .unwrap() + .downcast::() + .unwrap(); + + editor.update_in(&mut cx, |editor, window, cx| { + let buffer = editor.buffer().read(cx).as_singleton().unwrap(); + buffer.update(cx, |buffer, cx| { + buffer.set_language(Some(language.clone()), cx) + }); + + let snapshot = editor.buffer().read(cx).snapshot(cx); + editor.runnables.insert( + buffer.read(cx).remote_id(), + 0, + buffer.read(cx).version(), + RunnableTasks { + templates: Vec::new(), + offset: snapshot.anchor_before(MultiBufferOffset(0)), + column: 0, + extra_variables: HashMap::default(), + context_range: BufferOffset(0)..BufferOffset(0), + }, + ); + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_ranges([Point::new(0, 0)..Point::new(0, 0)]) + }); + + editor.toggle_code_actions( + &ToggleCodeActions { + deployed_from: None, + quick_launch: false, + }, + window, + cx, + ); + }); + + cx.run_until_parked(); + + workspace.update_in(&mut cx, |workspace, _, _| { + assert!(!workspace.notification_ids().is_empty()); + }); +} + +#[gpui::test] +async fn test_folding_buffers(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let sample_text_1 = "aaaa\nbbbb\ncccc\ndddd\neeee\nffff\ngggg\nhhhh\niiii\njjjj".to_string(); + let sample_text_2 = "llll\nmmmm\nnnnn\noooo\npppp\nqqqq\nrrrr\nssss\ntttt\nuuuu".to_string(); + let sample_text_3 = "vvvv\nwwww\nxxxx\nyyyy\nzzzz\n1111\n2222\n3333\n4444\n5555".to_string(); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/a"), + json!({ + "first.rs": sample_text_1, + "second.rs": sample_text_2, + "third.rs": sample_text_3, + }), + ) + .await; + let project = Project::test(fs, [path!("/a").as_ref()], cx).await; + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(*window, cx); + let worktree = project.update(cx, |project, cx| { + let mut worktrees = project.worktrees(cx).collect::>(); + assert_eq!(worktrees.len(), 1); + worktrees.pop().unwrap() + }); + let worktree_id = worktree.update(cx, |worktree, _| worktree.id()); + + let buffer_1 = project + .update(cx, |project, cx| { + project.open_buffer((worktree_id, rel_path("first.rs")), cx) + }) + .await + .unwrap(); + let buffer_2 = project + .update(cx, |project, cx| { + project.open_buffer((worktree_id, rel_path("second.rs")), cx) + }) + .await + .unwrap(); + let buffer_3 = project + .update(cx, |project, cx| { + project.open_buffer((worktree_id, rel_path("third.rs")), cx) + }) + .await + .unwrap(); + + let multi_buffer = cx.new(|cx| { + let mut multi_buffer = MultiBuffer::new(ReadWrite); + multi_buffer.set_excerpts_for_path( + PathKey::sorted(0), + buffer_1.clone(), + [ + Point::new(0, 0)..Point::new(2, 0), + Point::new(5, 0)..Point::new(6, 0), + Point::new(9, 0)..Point::new(10, 4), + ], + 0, + cx, + ); + multi_buffer.set_excerpts_for_path( + PathKey::sorted(1), + buffer_2.clone(), + [ + Point::new(0, 0)..Point::new(2, 0), + Point::new(5, 0)..Point::new(6, 0), + Point::new(9, 0)..Point::new(10, 4), + ], + 0, + cx, + ); + multi_buffer.set_excerpts_for_path( + PathKey::sorted(2), + buffer_3.clone(), + [ + Point::new(0, 0)..Point::new(2, 0), + Point::new(5, 0)..Point::new(6, 0), + Point::new(9, 0)..Point::new(10, 4), + ], + 0, + cx, + ); + multi_buffer + }); + let multi_buffer_editor = cx.new_window_entity(|window, cx| { + Editor::new( + EditorMode::full(), + multi_buffer.clone(), + Some(project.clone()), + window, + cx, + ) + }); + + assert_eq!( + multi_buffer_editor.update(cx, |editor, cx| editor.display_text(cx)), + "\n\naaaa\nbbbb\ncccc\n\nffff\ngggg\n\njjjj\n\n\nllll\nmmmm\nnnnn\n\nqqqq\nrrrr\n\nuuuu\n\n\nvvvv\nwwww\nxxxx\n\n1111\n2222\n\n5555", + ); + + multi_buffer_editor.update(cx, |editor, cx| { + editor.fold_buffer(buffer_1.read(cx).remote_id(), cx) + }); + assert_eq!( + multi_buffer_editor.update(cx, |editor, cx| editor.display_text(cx)), + "\n\n\n\nllll\nmmmm\nnnnn\n\nqqqq\nrrrr\n\nuuuu\n\n\nvvvv\nwwww\nxxxx\n\n1111\n2222\n\n5555", + "After folding the first buffer, its text should not be displayed" + ); + + multi_buffer_editor.update(cx, |editor, cx| { + editor.fold_buffer(buffer_2.read(cx).remote_id(), cx) + }); + assert_eq!( + multi_buffer_editor.update(cx, |editor, cx| editor.display_text(cx)), + "\n\n\n\n\n\nvvvv\nwwww\nxxxx\n\n1111\n2222\n\n5555", + "After folding the second buffer, its text should not be displayed" + ); + + multi_buffer_editor.update(cx, |editor, cx| { + editor.fold_buffer(buffer_3.read(cx).remote_id(), cx) + }); + assert_eq!( + multi_buffer_editor.update(cx, |editor, cx| editor.display_text(cx)), + "\n\n\n\n\n", + "After folding the third buffer, its text should not be displayed" + ); + + // Emulate selection inside the fold logic, that should work + multi_buffer_editor.update_in(cx, |editor, window, cx| { + editor + .snapshot(window, cx) + .next_line_boundary(Point::new(0, 4)); + }); + + multi_buffer_editor.update(cx, |editor, cx| { + editor.unfold_buffer(buffer_2.read(cx).remote_id(), cx) + }); + assert_eq!( + multi_buffer_editor.update(cx, |editor, cx| editor.display_text(cx)), + "\n\n\n\nllll\nmmmm\nnnnn\n\nqqqq\nrrrr\n\nuuuu\n\n", + "After unfolding the second buffer, its text should be displayed" + ); + + // Typing inside of buffer 1 causes that buffer to be unfolded. + multi_buffer_editor.update_in(cx, |editor, window, cx| { + assert_eq!( + multi_buffer + .read(cx) + .snapshot(cx) + .text_for_range(Point::new(1, 0)..Point::new(1, 4)) + .collect::(), + "bbbb" + ); + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| { + selections.select_ranges(vec![Point::new(1, 0)..Point::new(1, 0)]); + }); + editor.handle_input("B", window, cx); + }); + + assert_eq!( + multi_buffer_editor.update(cx, |editor, cx| editor.display_text(cx)), + "\n\naaaa\nBbbbb\ncccc\n\nffff\ngggg\n\njjjj\n\n\nllll\nmmmm\nnnnn\n\nqqqq\nrrrr\n\nuuuu\n\n", + "After unfolding the first buffer, its and 2nd buffer's text should be displayed" + ); + + multi_buffer_editor.update(cx, |editor, cx| { + editor.unfold_buffer(buffer_3.read(cx).remote_id(), cx) + }); + assert_eq!( + multi_buffer_editor.update(cx, |editor, cx| editor.display_text(cx)), + "\n\naaaa\nBbbbb\ncccc\n\nffff\ngggg\n\njjjj\n\n\nllll\nmmmm\nnnnn\n\nqqqq\nrrrr\n\nuuuu\n\n\nvvvv\nwwww\nxxxx\n\n1111\n2222\n\n5555", + "After unfolding the all buffers, all original text should be displayed" + ); +} + +#[gpui::test] +async fn test_folded_buffers_cleared_on_excerpts_removed(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/root"), + json!({ + "file_a.txt": "File A\nFile A\nFile A", + "file_b.txt": "File B\nFile B\nFile B", + }), + ) + .await; + + let project = Project::test(fs, [path!("/root").as_ref()], cx).await; + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(*window, cx); + let worktree = project.update(cx, |project, cx| { + let mut worktrees = project.worktrees(cx).collect::>(); + assert_eq!(worktrees.len(), 1); + worktrees.pop().unwrap() + }); + let worktree_id = worktree.update(cx, |worktree, _| worktree.id()); + + let buffer_a = project + .update(cx, |project, cx| { + project.open_buffer((worktree_id, rel_path("file_a.txt")), cx) + }) + .await + .unwrap(); + let buffer_b = project + .update(cx, |project, cx| { + project.open_buffer((worktree_id, rel_path("file_b.txt")), cx) + }) + .await + .unwrap(); + + let multi_buffer = cx.new(|cx| { + let mut multi_buffer = MultiBuffer::new(ReadWrite); + let range_a = Point::new(0, 0)..Point::new(2, 4); + let range_b = Point::new(0, 0)..Point::new(2, 4); + + multi_buffer.set_excerpts_for_path(PathKey::sorted(0), buffer_a.clone(), [range_a], 0, cx); + multi_buffer.set_excerpts_for_path(PathKey::sorted(1), buffer_b.clone(), [range_b], 0, cx); + multi_buffer + }); + + let editor = cx.new_window_entity(|window, cx| { + Editor::new( + EditorMode::full(), + multi_buffer.clone(), + Some(project.clone()), + window, + cx, + ) + }); + + editor.update(cx, |editor, cx| { + editor.fold_buffer(buffer_a.read(cx).remote_id(), cx); + }); + assert!(editor.update(cx, |editor, cx| editor.has_any_buffer_folded(cx))); + + // When the excerpts for `buffer_a` are removed, a + // `multi_buffer::Event::ExcerptsRemoved` event is emitted, which should be + // picked up by the editor and update the display map accordingly. + multi_buffer.update(cx, |multi_buffer, cx| { + multi_buffer.remove_excerpts(PathKey::sorted(0), cx) + }); + assert!(!editor.update(cx, |editor, cx| editor.has_any_buffer_folded(cx))); +} + +#[gpui::test] +async fn test_folding_buffers_with_one_excerpt(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let sample_text_1 = "1111\n2222\n3333".to_string(); + let sample_text_2 = "4444\n5555\n6666".to_string(); + let sample_text_3 = "7777\n8888\n9999".to_string(); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/a"), + json!({ + "first.rs": sample_text_1, + "second.rs": sample_text_2, + "third.rs": sample_text_3, + }), + ) + .await; + let project = Project::test(fs, [path!("/a").as_ref()], cx).await; + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(*window, cx); + let worktree = project.update(cx, |project, cx| { + let mut worktrees = project.worktrees(cx).collect::>(); + assert_eq!(worktrees.len(), 1); + worktrees.pop().unwrap() + }); + let worktree_id = worktree.update(cx, |worktree, _| worktree.id()); + + let buffer_1 = project + .update(cx, |project, cx| { + project.open_buffer((worktree_id, rel_path("first.rs")), cx) + }) + .await + .unwrap(); + let buffer_2 = project + .update(cx, |project, cx| { + project.open_buffer((worktree_id, rel_path("second.rs")), cx) + }) + .await + .unwrap(); + let buffer_3 = project + .update(cx, |project, cx| { + project.open_buffer((worktree_id, rel_path("third.rs")), cx) + }) + .await + .unwrap(); + + let multi_buffer = cx.new(|cx| { + let mut multi_buffer = MultiBuffer::new(ReadWrite); + multi_buffer.set_excerpts_for_path( + PathKey::sorted(0), + buffer_1.clone(), + [Point::new(0, 0)..Point::new(3, 0)], + 0, + cx, + ); + multi_buffer.set_excerpts_for_path( + PathKey::sorted(1), + buffer_2.clone(), + [Point::new(0, 0)..Point::new(3, 0)], + 0, + cx, + ); + multi_buffer.set_excerpts_for_path( + PathKey::sorted(2), + buffer_3.clone(), + [Point::new(0, 0)..Point::new(3, 0)], + 0, + cx, + ); + multi_buffer + }); + + let multi_buffer_editor = cx.new_window_entity(|window, cx| { + Editor::new( + EditorMode::full(), + multi_buffer, + Some(project.clone()), + window, + cx, + ) + }); + + let full_text = "\n\n1111\n2222\n3333\n\n\n4444\n5555\n6666\n\n\n7777\n8888\n9999"; + assert_eq!( + multi_buffer_editor.update(cx, |editor, cx| editor.display_text(cx)), + full_text, + ); + + multi_buffer_editor.update(cx, |editor, cx| { + editor.fold_buffer(buffer_1.read(cx).remote_id(), cx) + }); + assert_eq!( + multi_buffer_editor.update(cx, |editor, cx| editor.display_text(cx)), + "\n\n\n\n4444\n5555\n6666\n\n\n7777\n8888\n9999", + "After folding the first buffer, its text should not be displayed" + ); + + multi_buffer_editor.update(cx, |editor, cx| { + editor.fold_buffer(buffer_2.read(cx).remote_id(), cx) + }); + + assert_eq!( + multi_buffer_editor.update(cx, |editor, cx| editor.display_text(cx)), + "\n\n\n\n\n\n7777\n8888\n9999", + "After folding the second buffer, its text should not be displayed" + ); + + multi_buffer_editor.update(cx, |editor, cx| { + editor.fold_buffer(buffer_3.read(cx).remote_id(), cx) + }); + assert_eq!( + multi_buffer_editor.update(cx, |editor, cx| editor.display_text(cx)), + "\n\n\n\n\n", + "After folding the third buffer, its text should not be displayed" + ); + + multi_buffer_editor.update(cx, |editor, cx| { + editor.unfold_buffer(buffer_2.read(cx).remote_id(), cx) + }); + assert_eq!( + multi_buffer_editor.update(cx, |editor, cx| editor.display_text(cx)), + "\n\n\n\n4444\n5555\n6666\n\n", + "After unfolding the second buffer, its text should be displayed" + ); + + multi_buffer_editor.update(cx, |editor, cx| { + editor.unfold_buffer(buffer_1.read(cx).remote_id(), cx) + }); + assert_eq!( + multi_buffer_editor.update(cx, |editor, cx| editor.display_text(cx)), + "\n\n1111\n2222\n3333\n\n\n4444\n5555\n6666\n\n", + "After unfolding the first buffer, its text should be displayed" + ); + + multi_buffer_editor.update(cx, |editor, cx| { + editor.unfold_buffer(buffer_3.read(cx).remote_id(), cx) + }); + assert_eq!( + multi_buffer_editor.update(cx, |editor, cx| editor.display_text(cx)), + full_text, + "After unfolding all buffers, all original text should be displayed" + ); +} + +#[gpui::test] +async fn test_folding_buffer_when_multibuffer_has_only_one_excerpt(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let sample_text = "aaaa\nbbbb\ncccc\ndddd\neeee\nffff\ngggg\nhhhh\niiii\njjjj".to_string(); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/a"), + json!({ + "main.rs": sample_text, + }), + ) + .await; + let project = Project::test(fs, [path!("/a").as_ref()], cx).await; + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(*window, cx); + let worktree = project.update(cx, |project, cx| { + let mut worktrees = project.worktrees(cx).collect::>(); + assert_eq!(worktrees.len(), 1); + worktrees.pop().unwrap() + }); + let worktree_id = worktree.update(cx, |worktree, _| worktree.id()); + + let buffer_1 = project + .update(cx, |project, cx| { + project.open_buffer((worktree_id, rel_path("main.rs")), cx) + }) + .await + .unwrap(); + + let multi_buffer = cx.new(|cx| { + let mut multi_buffer = MultiBuffer::new(ReadWrite); + multi_buffer.set_excerpts_for_path( + PathKey::sorted(0), + buffer_1.clone(), + [Point::new(0, 0) + ..Point::new( + sample_text.chars().filter(|&c| c == '\n').count() as u32 + 1, + 0, + )], + 0, + cx, + ); + multi_buffer + }); + let multi_buffer_editor = cx.new_window_entity(|window, cx| { + Editor::new( + EditorMode::full(), + multi_buffer, + Some(project.clone()), + window, + cx, + ) + }); + + let selection_range = Point::new(1, 0)..Point::new(2, 0); + multi_buffer_editor.update_in(cx, |editor, window, cx| { + let multi_buffer_snapshot = editor.buffer().read(cx).snapshot(cx); + let highlight_range = selection_range.clone().to_anchors(&multi_buffer_snapshot); + editor.highlight_text( + HighlightKey::Editor, + vec![highlight_range.clone()], + HighlightStyle::color(Hsla::green()), + cx, + ); + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_ranges(Some(highlight_range)) + }); + }); + + let full_text = format!("\n\n{sample_text}"); + assert_eq!( + multi_buffer_editor.update(cx, |editor, cx| editor.display_text(cx)), + full_text, + ); +} + +#[gpui::test] +async fn test_multi_buffer_navigation_with_folded_buffers(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + cx.update(|cx| { + let default_key_bindings = settings::KeymapFile::load_asset_allow_partial_failure( + "keymaps/default-linux.json", + cx, + ) + .unwrap(); + cx.bind_keys(default_key_bindings); + }); + + let (editor, cx) = cx.add_window_view(|window, cx| { + let multi_buffer = MultiBuffer::build_multi( + [ + ("a0\nb0\nc0\nd0\ne0\n", vec![Point::row_range(0..2)]), + ("a1\nb1\nc1\nd1\ne1\n", vec![Point::row_range(0..2)]), + ("a2\nb2\nc2\nd2\ne2\n", vec![Point::row_range(0..2)]), + ("a3\nb3\nc3\nd3\ne3\n", vec![Point::row_range(0..2)]), + ], + cx, + ); + let mut editor = Editor::new(EditorMode::full(), multi_buffer.clone(), None, window, cx); + + let buffer_ids = multi_buffer + .read(cx) + .snapshot(cx) + .excerpts() + .map(|excerpt| excerpt.context.start.buffer_id) + .collect::>(); + // fold all but the second buffer, so that we test navigating between two + // adjacent folded buffers, as well as folded buffers at the start and + // end the multibuffer + editor.fold_buffer(buffer_ids[0], cx); + editor.fold_buffer(buffer_ids[2], cx); + editor.fold_buffer(buffer_ids[3], cx); + + editor + }); + cx.simulate_resize(size(px(1000.), px(1000.))); + + let mut cx = EditorTestContext::for_editor_in(editor.clone(), cx).await; + cx.assert_excerpts_with_selections(indoc! {" + [EXCERPT] + ˇ[FOLDED] + [EXCERPT] + a1 + b1 + [EXCERPT] + [FOLDED] + [EXCERPT] + [FOLDED] + " + }); + cx.simulate_keystroke("down"); + cx.assert_excerpts_with_selections(indoc! {" + [EXCERPT] + [FOLDED] + [EXCERPT] + ˇa1 + b1 + [EXCERPT] + [FOLDED] + [EXCERPT] + [FOLDED] + " + }); + cx.simulate_keystroke("down"); + cx.assert_excerpts_with_selections(indoc! {" + [EXCERPT] + [FOLDED] + [EXCERPT] + a1 + ˇb1 + [EXCERPT] + [FOLDED] + [EXCERPT] + [FOLDED] + " + }); + cx.simulate_keystroke("down"); + cx.assert_excerpts_with_selections(indoc! {" + [EXCERPT] + [FOLDED] + [EXCERPT] + a1 + b1 + ˇ[EXCERPT] + [FOLDED] + [EXCERPT] + [FOLDED] + " + }); + cx.simulate_keystroke("down"); + cx.assert_excerpts_with_selections(indoc! {" + [EXCERPT] + [FOLDED] + [EXCERPT] + a1 + b1 + [EXCERPT] + ˇ[FOLDED] + [EXCERPT] + [FOLDED] + " + }); + for _ in 0..5 { + cx.simulate_keystroke("down"); + cx.assert_excerpts_with_selections(indoc! {" + [EXCERPT] + [FOLDED] + [EXCERPT] + a1 + b1 + [EXCERPT] + [FOLDED] + [EXCERPT] + ˇ[FOLDED] + " + }); + } + + cx.simulate_keystroke("up"); + cx.assert_excerpts_with_selections(indoc! {" + [EXCERPT] + [FOLDED] + [EXCERPT] + a1 + b1 + [EXCERPT] + ˇ[FOLDED] + [EXCERPT] + [FOLDED] + " + }); + cx.simulate_keystroke("up"); + cx.assert_excerpts_with_selections(indoc! {" + [EXCERPT] + [FOLDED] + [EXCERPT] + a1 + b1 + ˇ[EXCERPT] + [FOLDED] + [EXCERPT] + [FOLDED] + " + }); + cx.simulate_keystroke("up"); + cx.assert_excerpts_with_selections(indoc! {" + [EXCERPT] + [FOLDED] + [EXCERPT] + a1 + ˇb1 + [EXCERPT] + [FOLDED] + [EXCERPT] + [FOLDED] + " + }); + cx.simulate_keystroke("up"); + cx.assert_excerpts_with_selections(indoc! {" + [EXCERPT] + [FOLDED] + [EXCERPT] + ˇa1 + b1 + [EXCERPT] + [FOLDED] + [EXCERPT] + [FOLDED] + " + }); + for _ in 0..5 { + cx.simulate_keystroke("up"); + cx.assert_excerpts_with_selections(indoc! {" + [EXCERPT] + ˇ[FOLDED] + [EXCERPT] + a1 + b1 + [EXCERPT] + [FOLDED] + [EXCERPT] + [FOLDED] + " + }); + } +} + +#[gpui::test] +async fn test_edit_prediction_text(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + // Simple insertion + assert_highlighted_edits( + "Hello, world!", + vec![(Point::new(0, 6)..Point::new(0, 6), " beautiful".into())], + true, + cx, + &|highlighted_edits, cx| { + assert_eq!(highlighted_edits.text, "Hello, beautiful world!"); + assert_eq!(highlighted_edits.highlights.len(), 1); + assert_eq!(highlighted_edits.highlights[0].0, 6..16); + assert_eq!( + highlighted_edits.highlights[0].1.background_color, + Some(cx.theme().status().created_background) + ); + }, + ) + .await; + + // Replacement + assert_highlighted_edits( + "This is a test.", + vec![(Point::new(0, 0)..Point::new(0, 4), "That".into())], + false, + cx, + &|highlighted_edits, cx| { + assert_eq!(highlighted_edits.text, "That is a test."); + assert_eq!(highlighted_edits.highlights.len(), 1); + assert_eq!(highlighted_edits.highlights[0].0, 0..4); + assert_eq!( + highlighted_edits.highlights[0].1.background_color, + Some(cx.theme().status().created_background) + ); + }, + ) + .await; + + // Multiple edits + assert_highlighted_edits( + "Hello, world!", + vec![ + (Point::new(0, 0)..Point::new(0, 5), "Greetings".into()), + (Point::new(0, 12)..Point::new(0, 12), " and universe".into()), + ], + false, + cx, + &|highlighted_edits, cx| { + assert_eq!(highlighted_edits.text, "Greetings, world and universe!"); + assert_eq!(highlighted_edits.highlights.len(), 2); + assert_eq!(highlighted_edits.highlights[0].0, 0..9); + assert_eq!(highlighted_edits.highlights[1].0, 16..29); + assert_eq!( + highlighted_edits.highlights[0].1.background_color, + Some(cx.theme().status().created_background) + ); + assert_eq!( + highlighted_edits.highlights[1].1.background_color, + Some(cx.theme().status().created_background) + ); + }, + ) + .await; + + // Multiple lines with edits + assert_highlighted_edits( + "First line\nSecond line\nThird line\nFourth line", + vec![ + (Point::new(1, 7)..Point::new(1, 11), "modified".to_string()), + ( + Point::new(2, 0)..Point::new(2, 10), + "New third line".to_string(), + ), + (Point::new(3, 6)..Point::new(3, 6), " updated".to_string()), + ], + false, + cx, + &|highlighted_edits, cx| { + assert_eq!( + highlighted_edits.text, + "Second modified\nNew third line\nFourth updated line" + ); + assert_eq!(highlighted_edits.highlights.len(), 3); + assert_eq!(highlighted_edits.highlights[0].0, 7..15); // "modified" + assert_eq!(highlighted_edits.highlights[1].0, 16..30); // "New third line" + assert_eq!(highlighted_edits.highlights[2].0, 37..45); // " updated" + for highlight in &highlighted_edits.highlights { + assert_eq!( + highlight.1.background_color, + Some(cx.theme().status().created_background) + ); + } + }, + ) + .await; +} + +#[gpui::test] +async fn test_edit_prediction_text_with_deletions(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + // Deletion + assert_highlighted_edits( + "Hello, world!", + vec![(Point::new(0, 5)..Point::new(0, 11), "".to_string())], + true, + cx, + &|highlighted_edits, cx| { + assert_eq!(highlighted_edits.text, "Hello, world!"); + assert_eq!(highlighted_edits.highlights.len(), 1); + assert_eq!(highlighted_edits.highlights[0].0, 5..11); + assert_eq!( + highlighted_edits.highlights[0].1.background_color, + Some(cx.theme().status().deleted_background) + ); + }, + ) + .await; + + // Insertion + assert_highlighted_edits( + "Hello, world!", + vec![(Point::new(0, 6)..Point::new(0, 6), " digital".to_string())], + true, + cx, + &|highlighted_edits, cx| { + assert_eq!(highlighted_edits.highlights.len(), 1); + assert_eq!(highlighted_edits.highlights[0].0, 6..14); + assert_eq!( + highlighted_edits.highlights[0].1.background_color, + Some(cx.theme().status().created_background) + ); + }, + ) + .await; +} + +async fn assert_highlighted_edits( + text: &str, + edits: Vec<(Range, String)>, + include_deletions: bool, + cx: &mut TestAppContext, + assertion_fn: &dyn Fn(HighlightedText, &App), +) { + let window = cx.add_window(|window, cx| { + let buffer = MultiBuffer::build_simple(text, cx); + Editor::new(EditorMode::full(), buffer, None, window, cx) + }); + let cx = &mut VisualTestContext::from_window(*window, cx); + + let (buffer, snapshot) = window + .update(cx, |editor, _window, cx| { + ( + editor.buffer().clone(), + editor.buffer().read(cx).snapshot(cx), + ) + }) + .unwrap(); + + let edits = edits + .into_iter() + .map(|(range, edit)| { + ( + snapshot.anchor_after(range.start)..snapshot.anchor_before(range.end), + edit, + ) + }) + .collect::>(); + + let text_anchor_edits = edits + .clone() + .into_iter() + .map(|(range, edit)| { + ( + range.start.expect_text_anchor()..range.end.expect_text_anchor(), + edit.into(), + ) + }) + .collect::>(); + + let edit_preview = window + .update(cx, |_, _window, cx| { + buffer + .read(cx) + .as_singleton() + .unwrap() + .read(cx) + .preview_edits(text_anchor_edits.into(), cx) + }) + .unwrap() + .await; + + cx.update(|_window, cx| { + let highlighted_edits = edit_prediction_edit_text( + snapshot.as_singleton().unwrap(), + &edits, + &edit_preview, + include_deletions, + &snapshot, + cx, + ); + assertion_fn(highlighted_edits, cx) + }); +} + +#[track_caller] +fn assert_breakpoint( + breakpoints: &BTreeMap, Vec>, + path: &Arc, + expected: Vec<(u32, Breakpoint)>, +) { + if expected.is_empty() { + assert!(!breakpoints.contains_key(path), "{}", path.display()); + } else { + let mut breakpoint = breakpoints + .get(path) + .unwrap() + .iter() + .map(|breakpoint| { + ( + breakpoint.row, + Breakpoint { + message: breakpoint.message.clone(), + state: breakpoint.state, + condition: breakpoint.condition.clone(), + hit_condition: breakpoint.hit_condition.clone(), + }, + ) + }) + .collect::>(); + + breakpoint.sort_by_key(|(cached_position, _)| *cached_position); + + assert_eq!(expected, breakpoint); + } +} + +fn add_log_breakpoint_at_cursor( + editor: &mut Editor, + log_message: &str, + window: &mut Window, + cx: &mut Context, +) { + let (anchor, bp) = editor + .breakpoints_at_cursors(window, cx) + .first() + .and_then(|(anchor, bp)| bp.as_ref().map(|bp| (*anchor, bp.clone()))) + .unwrap_or_else(|| { + let snapshot = editor.snapshot(window, cx); + let cursor_position: Point = + editor.selections.newest(&snapshot.display_snapshot).head(); + + let breakpoint_position = snapshot + .buffer_snapshot() + .anchor_before(Point::new(cursor_position.row, 0)); + + (breakpoint_position, Breakpoint::new_log(log_message)) + }); + + editor.edit_breakpoint_at_anchor( + anchor, + bp, + BreakpointEditAction::EditLogMessage(log_message.into()), + cx, + ); +} + +#[gpui::test] +fn test_gutter_button_tooltip_updates_intent_with_secondary_modifier(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let focus_handle = cx.update(|cx| cx.focus_handle()); + let tooltip = GutterButtonTooltip { + primary: GutterButtonIntent::SetBreakpoint, + secondary: GutterButtonIntent::SetBookmark, + focus_handle, + on_render: None, + }; + + let primary_intent = tooltip.active_intent(Modifiers::none()); + assert_eq!(primary_intent, GutterButtonIntent::SetBreakpoint); + assert!(primary_intent.action().as_any().is::()); + + let secondary_intent = tooltip.active_intent(Modifiers::secondary_key()); + assert_eq!(secondary_intent, GutterButtonIntent::SetBookmark); + assert!(secondary_intent.action().as_any().is::()); + + // When both features are enabled, the meta text advertises the + // modifier-click alternative. + let meta = tooltip.meta_text(); + assert!(meta.contains("-click to add a bookmark"), "got: {meta}"); + assert!(!meta.contains("-click to add a breakpoint"), "got: {meta}"); + assert!(meta.contains("right-click for more options")); + + // When only one feature is enabled (primary == secondary), a + // modifier-click repeats the primary action, so the tooltip must not + // advertise it. + let single_feature_tooltip = GutterButtonTooltip { + primary: GutterButtonIntent::SetBreakpoint, + secondary: GutterButtonIntent::SetBreakpoint, + focus_handle: tooltip.focus_handle, + on_render: None, + }; + let meta = single_feature_tooltip.meta_text(); + assert_eq!(meta, "right-click for more options"); +} + +#[gpui::test] +fn test_gutter_button_tooltip_renders_modifier_transitions(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let renders = Rc::new(RefCell::new(Vec::new())); + let (tooltip, cx) = cx.add_window_view({ + let renders = renders.clone(); + move |_window, cx| GutterButtonTooltip { + primary: GutterButtonIntent::SetBreakpoint, + secondary: GutterButtonIntent::SetBookmark, + focus_handle: cx.focus_handle(), + on_render: Some(renders), + } + }); + cx.run_until_parked(); + + let assert_render = |expected_title: &str| { + let (title, meta) = renders.borrow().last().cloned().expect("tooltip rendered"); + assert_eq!(title, expected_title); + assert!(meta.contains("-click to add a bookmark"), "got: {meta}"); + assert!(!meta.contains("-click to add a breakpoint"), "got: {meta}"); + }; + + assert_render("Set Breakpoint"); + + cx.simulate_modifiers_change(Modifiers::secondary_key()); + tooltip.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + assert_render("Set Bookmark"); + + cx.simulate_modifiers_change(Modifiers::none()); + tooltip.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + assert_render("Set Breakpoint"); +} + +#[gpui::test] +async fn test_breakpoint_toggling(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let sample_text = "First line\nSecond line\nThird line\nFourth line".to_string(); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/a"), + json!({ + "main.rs": sample_text, + }), + ) + .await; + let project = Project::test(fs, [path!("/a").as_ref()], cx).await; + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(*window, cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/a"), + json!({ + "main.rs": sample_text, + }), + ) + .await; + let project = Project::test(fs, [path!("/a").as_ref()], cx).await; + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(*window, cx); + let worktree_id = workspace.update_in(cx, |workspace, _window, cx| { + workspace.project().update(cx, |project, cx| { + project.worktrees(cx).next().unwrap().read(cx).id() + }) + }); + + let buffer = project + .update(cx, |project, cx| { + project.open_buffer((worktree_id, rel_path("main.rs")), cx) + }) + .await + .unwrap(); + + let (editor, cx) = cx.add_window_view(|window, cx| { + Editor::new( + EditorMode::full(), + MultiBuffer::build_from_buffer(buffer, cx), + Some(project.clone()), + window, + cx, + ) + }); + + let project_path = editor.update(cx, |editor, cx| editor.active_project_path(cx).unwrap()); + let abs_path = project.read_with(cx, |project, cx| { + project + .absolute_path(&project_path, cx) + .map(Arc::from) + .unwrap() + }); + + // assert we can add breakpoint on the first line + editor.update_in(cx, |editor, window, cx| { + editor.toggle_breakpoint(&actions::ToggleBreakpoint, window, cx); + editor.move_to_end(&MoveToEnd, window, cx); + editor.toggle_breakpoint(&actions::ToggleBreakpoint, window, cx); + }); + + let breakpoints = editor.update(cx, |editor, cx| { + editor + .breakpoint_store() + .as_ref() + .unwrap() + .read(cx) + .all_source_breakpoints(cx) + }); + + assert_eq!(1, breakpoints.len()); + assert_breakpoint( + &breakpoints, + &abs_path, + vec![ + (0, Breakpoint::new_standard()), + (3, Breakpoint::new_standard()), + ], + ); + + editor.update_in(cx, |editor, window, cx| { + editor.move_to_beginning(&MoveToBeginning, window, cx); + editor.toggle_breakpoint(&actions::ToggleBreakpoint, window, cx); + }); + + let breakpoints = editor.update(cx, |editor, cx| { + editor + .breakpoint_store() + .as_ref() + .unwrap() + .read(cx) + .all_source_breakpoints(cx) + }); + + assert_eq!(1, breakpoints.len()); + assert_breakpoint( + &breakpoints, + &abs_path, + vec![(3, Breakpoint::new_standard())], + ); + + editor.update_in(cx, |editor, window, cx| { + editor.move_to_end(&MoveToEnd, window, cx); + editor.toggle_breakpoint(&actions::ToggleBreakpoint, window, cx); + }); + + let breakpoints = editor.update(cx, |editor, cx| { + editor + .breakpoint_store() + .as_ref() + .unwrap() + .read(cx) + .all_source_breakpoints(cx) + }); + + assert_eq!(0, breakpoints.len()); + assert_breakpoint(&breakpoints, &abs_path, vec![]); +} + +async fn build_gutter_hover_test_editor(saved: bool, cx: &mut TestAppContext) -> EditorTestContext { + init_test(cx, |_| {}); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/a"), + json!({ + "main.rs": "fn main() {}\n", + }), + ) + .await; + let project = Project::test(fs, [path!("/a").as_ref()], cx).await; + + let buffer = if saved { + let worktree_id = project.read_with(cx, |project, cx| { + project.worktrees(cx).next().unwrap().read(cx).id() + }); + project + .update(cx, |project, cx| { + project.open_buffer((worktree_id, rel_path("main.rs")), cx) + }) + .await + .unwrap() + } else { + let buffer = project + .update(cx, |project, cx| project.create_buffer(None, true, cx)) + .await + .unwrap(); + buffer.update(cx, |buffer, cx| { + buffer.edit([(0..0, "fn main() {}\n")], None, cx); + }); + buffer + }; + + let window = cx.add_window(|window, cx| { + let editor = build_editor_with_project( + project, + MultiBuffer::build_from_buffer(buffer, cx), + window, + cx, + ); + window.focus(&editor.focus_handle(cx), cx); + editor + }); + + EditorTestContext::for_editor(window, cx).await +} + +fn hover_over_gutter_row_zero(cx: &mut EditorTestContext) { + cx.update(|window, cx| { + window.refresh(); + let _ = window.draw(cx); + }); + + let gutter_bounds = cx.update_editor(|editor, _, _| { + editor + .last_position_map + .as_ref() + .expect("expected editor position map") + .gutter_hitbox + .bounds + }); + + let hover_position = gutter_bounds.center(); + cx.simulate_mouse_move(hover_position, None, Modifiers::none()); +} + +#[gpui::test] +async fn test_gutter_hover_button_shown_for_saved_buffer(cx: &mut TestAppContext) { + let mut cx = build_gutter_hover_test_editor(true, cx).await; + + hover_over_gutter_row_zero(&mut cx); + cx.update_editor(|editor, _, _| { + assert!( + editor.gutter_hover_button.0.is_some(), + "expected gutter hover button to be armed for a saved buffer" + ); + assert!(!editor.gutter_hover_button.0.as_ref().unwrap().is_active); + }); + + cx.executor().advance_clock(Duration::from_millis(250)); + cx.run_until_parked(); + cx.update_editor(|editor, _, _| { + assert!( + editor + .gutter_hover_button + .0 + .as_ref() + .is_some_and(|indicator| indicator.is_active), + "expected gutter hover button to become active after the debounce" + ); + }); +} + +#[gpui::test] +async fn test_gutter_hover_button_hidden_for_unsaved_buffer(cx: &mut TestAppContext) { + let mut cx = build_gutter_hover_test_editor(false, cx).await; + + hover_over_gutter_row_zero(&mut cx); + cx.update_editor(|editor, _, _| { + assert!( + editor.gutter_hover_button.0.is_none(), + "expected no gutter hover button for an unsaved buffer" + ); + assert!( + editor.gutter_hover_button.1.is_none(), + "expected no debounce task to be armed for an unsaved buffer" + ); + }); + + cx.executor().advance_clock(Duration::from_millis(250)); + cx.run_until_parked(); + cx.update_editor(|editor, _, _| { + assert!(editor.gutter_hover_button.0.is_none()); + }); +} + +fn right_click_gutter_row_zero(cx: &mut EditorTestContext) { + cx.update(|window, cx| { + window.refresh(); + let _ = window.draw(cx); + }); + + let gutter_bounds = cx.update_editor(|editor, _, _| { + editor + .last_position_map + .as_ref() + .expect("expected editor position map") + .gutter_hitbox + .bounds + }); + + let click_position = gutter_bounds.center(); + cx.simulate_mouse_down(click_position, MouseButton::Right, Modifiers::none()); +} + +#[gpui::test] +async fn test_gutter_context_menu_shown_for_saved_buffer(cx: &mut TestAppContext) { + let mut cx = build_gutter_hover_test_editor(true, cx).await; + + right_click_gutter_row_zero(&mut cx); + cx.update_editor(|editor, _, _| { + assert!( + editor.mouse_context_menu.is_some(), + "expected gutter context menu to open for a saved buffer" + ); + }); +} + +#[gpui::test] +async fn test_gutter_context_menu_hidden_for_unsaved_buffer(cx: &mut TestAppContext) { + let mut cx = build_gutter_hover_test_editor(false, cx).await; + + right_click_gutter_row_zero(&mut cx); + cx.update_editor(|editor, _, _| { + assert!( + editor.mouse_context_menu.is_none(), + "expected no gutter context menu for an unsaved buffer" + ); + }); +} + +#[gpui::test] +async fn test_gutter_context_menu_anchor_in_multibuffer(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let fs = FakeFs::new(cx.executor()); + let root_path = path!("/root"); + fs.insert_tree( + root_path, + json!({ + "first.rs": "fn one() {}\nfn two() {}\nfn three() {}\n", + "second.rs": "fn four() {}\nfn five() {}\nfn six() {}\n", + }), + ) + .await; + + let project = Project::test(fs, [root_path.as_ref()], cx).await; + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(*window, cx); + let worktree_id = project.read_with(cx, |project, cx| { + project.worktrees(cx).next().unwrap().read(cx).id() + }); + + let buffer_a = project + .update(cx, |project, cx| { + project.open_buffer((worktree_id, rel_path("first.rs")), cx) + }) + .await + .unwrap(); + + let buffer_b = project + .update(cx, |project, cx| { + project.open_buffer((worktree_id, rel_path("second.rs")), cx) + }) + .await + .unwrap(); + + let multi_buffer = cx.new(|cx| { + let mut multi_buffer = MultiBuffer::new(ReadWrite); + + multi_buffer.set_excerpts_for_path( + PathKey::sorted(0), + buffer_a.clone(), + [Point::new(0, 0)..Point::new(2, 0)], + 0, + cx, + ); + + multi_buffer.set_excerpts_for_path( + PathKey::sorted(1), + buffer_b.clone(), + [Point::new(0, 0)..Point::new(2, 0)], + 0, + cx, + ); + + multi_buffer + }); + + let (editor, cx) = cx.add_window_view(|window, cx| { + Editor::new( + EditorMode::full(), + multi_buffer, + Some(project.clone()), + window, + cx, + ) + }); + + // Open the gutter context menu on a display row inside the second excerpt, + // ensuring that excerpt headers will now play a part in offsetting display + // rows from buffer rows. + editor.update_in(cx, |editor, window, cx| { + let display_snapshot = editor.display_snapshot(cx); + let buffer_snapshot = display_snapshot.buffer_snapshot(); + let buffer_anchor = buffer_b.read(cx).snapshot().anchor_before(Point::new(1, 0)); + let multibuffer_anchor = buffer_snapshot.anchor_in_buffer(buffer_anchor).unwrap(); + let buffer_point = buffer_snapshot.summary_for_anchor::(&multibuffer_anchor); + let display_row = display_snapshot + .point_to_display_point(buffer_point, Bias::Left) + .row(); + + assert_ne!( + display_row.0, buffer_point.row, + "display row should be offset from the multibuffer row by excerpt headers" + ); + + editor.set_gutter_context_menu(display_row, None, Default::default(), window, cx); + }); + + // We'll now confirm that the `MenuPosition`'s source has been set to the + // correct buffer anchor (1, 0), instead of simply taking the display row. + editor.update(cx, |editor, cx| { + let menu = editor.mouse_context_menu.as_ref().unwrap(); + let snapshot = editor.buffer().read(cx).snapshot(cx); + let MenuPosition::PinnedToEditor { source, .. } = &menu.position else { + panic!("expected gutter context menu to be pinned to the editor"); + }; + + let (buffer_anchor, buffer_snapshot) = snapshot + .anchor_to_buffer_anchor(*source) + .expect("menu anchor should resolve to a buffer"); + + assert_eq!(buffer_snapshot.remote_id(), buffer_b.read(cx).remote_id()); + assert_eq!( + buffer_snapshot.summary_for_anchor::(&buffer_anchor), + Point::new(1, 0), + ); + }); +} + +#[gpui::test] +async fn test_breakpoint_after_save_as_existing_path(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/a"), + json!({ + "main.rs": "First line\nSecond line\nThird line\nFourth line", + }), + ) + .await; + let project = Project::test(fs, [path!("/a").as_ref()], cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = + multi_workspace.read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone()); + + let worktree_id = workspace.update(cx, |workspace, cx| { + workspace.project().update(cx, |project, cx| { + project.worktrees(cx).next().unwrap().read(cx).id() + }) + }); + + let first_buffer = project + .update(cx, |project, cx| { + project.open_buffer((worktree_id, rel_path("main.rs")), cx) + }) + .await + .unwrap(); + + let (first_editor, cx) = cx.add_window_view(|window, cx| { + Editor::new( + EditorMode::full(), + MultiBuffer::build_from_buffer(first_buffer, cx), + Some(project.clone()), + window, + cx, + ) + }); + + first_editor.update_in(cx, |editor, window, cx| { + editor.toggle_breakpoint(&actions::ToggleBreakpoint, window, cx); + }); + + let replacement_buffer = project.update(cx, |project, cx| { + project.create_local_buffer("Alpha\nBeta\nGamma", None, true, cx) + }); + project + .update(cx, |project, cx| { + project.save_buffer_as( + replacement_buffer.clone(), + ProjectPath { + worktree_id, + path: rel_path("main.rs").into(), + }, + cx, + ) + }) + .await + .unwrap(); + + let (replacement_editor, cx) = cx.add_window_view(|window, cx| { + Editor::new( + EditorMode::full(), + MultiBuffer::build_from_buffer(replacement_buffer, cx), + Some(project.clone()), + window, + cx, + ) + }); + + replacement_editor.update_in(cx, |editor, window, cx| { + editor.move_down(&MoveDown, window, cx); + editor.toggle_breakpoint(&actions::ToggleBreakpoint, window, cx); + }); + + let project_path = + first_editor.update(cx, |editor, cx| editor.active_project_path(cx).unwrap()); + let abs_path = project.read_with(cx, |project, cx| { + project + .absolute_path(&project_path, cx) + .map(Arc::from) + .unwrap() + }); + + let breakpoints = first_editor.update(cx, |editor, cx| { + editor + .breakpoint_store() + .as_ref() + .unwrap() + .read(cx) + .source_breakpoints_from_path(&abs_path, cx) + }); + + assert_eq!( + vec![0, 1], + breakpoints + .into_iter() + .map(|breakpoint| breakpoint.row) + .collect::>() + ); +} + +#[gpui::test] +async fn test_log_breakpoint_editing(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let sample_text = "First line\nSecond line\nThird line\nFourth line".to_string(); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/a"), + json!({ + "main.rs": sample_text, + }), + ) + .await; + let project = Project::test(fs, [path!("/a").as_ref()], cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); + + let worktree_id = workspace.update(cx, |workspace, cx| { + workspace.project().update(cx, |project, cx| { + project.worktrees(cx).next().unwrap().read(cx).id() + }) + }); + + let buffer = project + .update(cx, |project, cx| { + project.open_buffer((worktree_id, rel_path("main.rs")), cx) + }) + .await + .unwrap(); + + let (editor, cx) = cx.add_window_view(|window, cx| { + Editor::new( + EditorMode::full(), + MultiBuffer::build_from_buffer(buffer, cx), + Some(project.clone()), + window, + cx, + ) + }); + + let project_path = editor.update(cx, |editor, cx| editor.active_project_path(cx).unwrap()); + let abs_path = project.read_with(cx, |project, cx| { + project + .absolute_path(&project_path, cx) + .map(Arc::from) + .unwrap() + }); + + editor.update_in(cx, |editor, window, cx| { + add_log_breakpoint_at_cursor(editor, "hello world", window, cx); + }); + + let breakpoints = editor.update(cx, |editor, cx| { + editor + .breakpoint_store() + .as_ref() + .unwrap() + .read(cx) + .all_source_breakpoints(cx) + }); + + assert_breakpoint( + &breakpoints, + &abs_path, + vec![(0, Breakpoint::new_log("hello world"))], + ); + + // Removing a log message from a log breakpoint should remove it + editor.update_in(cx, |editor, window, cx| { + add_log_breakpoint_at_cursor(editor, "", window, cx); + }); + + let breakpoints = editor.update(cx, |editor, cx| { + editor + .breakpoint_store() + .as_ref() + .unwrap() + .read(cx) + .all_source_breakpoints(cx) + }); + + assert_breakpoint(&breakpoints, &abs_path, vec![]); + + editor.update_in(cx, |editor, window, cx| { + editor.toggle_breakpoint(&actions::ToggleBreakpoint, window, cx); + editor.move_to_end(&MoveToEnd, window, cx); + editor.toggle_breakpoint(&actions::ToggleBreakpoint, window, cx); + // Not adding a log message to a standard breakpoint shouldn't remove it + add_log_breakpoint_at_cursor(editor, "", window, cx); + }); + + let breakpoints = editor.update(cx, |editor, cx| { + editor + .breakpoint_store() + .as_ref() + .unwrap() + .read(cx) + .all_source_breakpoints(cx) + }); + + assert_breakpoint( + &breakpoints, + &abs_path, + vec![ + (0, Breakpoint::new_standard()), + (3, Breakpoint::new_standard()), + ], + ); + + editor.update_in(cx, |editor, window, cx| { + add_log_breakpoint_at_cursor(editor, "hello world", window, cx); + }); + + let breakpoints = editor.update(cx, |editor, cx| { + editor + .breakpoint_store() + .as_ref() + .unwrap() + .read(cx) + .all_source_breakpoints(cx) + }); + + assert_breakpoint( + &breakpoints, + &abs_path, + vec![ + (0, Breakpoint::new_standard()), + (3, Breakpoint::new_log("hello world")), + ], + ); + + editor.update_in(cx, |editor, window, cx| { + add_log_breakpoint_at_cursor(editor, "hello Earth!!", window, cx); + }); + + let breakpoints = editor.update(cx, |editor, cx| { + editor + .breakpoint_store() + .as_ref() + .unwrap() + .read(cx) + .all_source_breakpoints(cx) + }); + + assert_breakpoint( + &breakpoints, + &abs_path, + vec![ + (0, Breakpoint::new_standard()), + (3, Breakpoint::new_log("hello Earth!!")), + ], + ); +} + +/// This also tests that Editor::breakpoint_at_cursor_head is working properly +/// we had some issues where we wouldn't find a breakpoint at Point {row: 0, col: 0} +/// or when breakpoints were placed out of order. This tests for a regression too +#[gpui::test] +async fn test_breakpoint_enabling_and_disabling(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let sample_text = "First line\nSecond line\nThird line\nFourth line".to_string(); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/a"), + json!({ + "main.rs": sample_text, + }), + ) + .await; + let project = Project::test(fs, [path!("/a").as_ref()], cx).await; + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(*window, cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/a"), + json!({ + "main.rs": sample_text, + }), + ) + .await; + let project = Project::test(fs, [path!("/a").as_ref()], cx).await; + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(*window, cx); + let worktree_id = workspace.update_in(cx, |workspace, _window, cx| { + workspace.project().update(cx, |project, cx| { + project.worktrees(cx).next().unwrap().read(cx).id() + }) + }); + + let buffer = project + .update(cx, |project, cx| { + project.open_buffer((worktree_id, rel_path("main.rs")), cx) + }) + .await + .unwrap(); + + let (editor, cx) = cx.add_window_view(|window, cx| { + Editor::new( + EditorMode::full(), + MultiBuffer::build_from_buffer(buffer, cx), + Some(project.clone()), + window, + cx, + ) + }); + + let project_path = editor.update(cx, |editor, cx| editor.active_project_path(cx).unwrap()); + let abs_path = project.read_with(cx, |project, cx| { + project + .absolute_path(&project_path, cx) + .map(Arc::from) + .unwrap() + }); + + // assert we can add breakpoint on the first line + editor.update_in(cx, |editor, window, cx| { + editor.toggle_breakpoint(&actions::ToggleBreakpoint, window, cx); + editor.move_to_end(&MoveToEnd, window, cx); + editor.toggle_breakpoint(&actions::ToggleBreakpoint, window, cx); + editor.move_up(&MoveUp, window, cx); + editor.toggle_breakpoint(&actions::ToggleBreakpoint, window, cx); + }); + + let breakpoints = editor.update(cx, |editor, cx| { + editor + .breakpoint_store() + .as_ref() + .unwrap() + .read(cx) + .all_source_breakpoints(cx) + }); + + assert_eq!(1, breakpoints.len()); + assert_breakpoint( + &breakpoints, + &abs_path, + vec![ + (0, Breakpoint::new_standard()), + (2, Breakpoint::new_standard()), + (3, Breakpoint::new_standard()), + ], + ); + + editor.update_in(cx, |editor, window, cx| { + editor.move_to_beginning(&MoveToBeginning, window, cx); + editor.disable_breakpoint(&actions::DisableBreakpoint, window, cx); + editor.move_to_end(&MoveToEnd, window, cx); + editor.disable_breakpoint(&actions::DisableBreakpoint, window, cx); + // Disabling a breakpoint that doesn't exist should do nothing + editor.move_up(&MoveUp, window, cx); + editor.move_up(&MoveUp, window, cx); + editor.disable_breakpoint(&actions::DisableBreakpoint, window, cx); + }); + + let breakpoints = editor.update(cx, |editor, cx| { + editor + .breakpoint_store() + .as_ref() + .unwrap() + .read(cx) + .all_source_breakpoints(cx) + }); + + let disable_breakpoint = { + let mut bp = Breakpoint::new_standard(); + bp.state = BreakpointState::Disabled; + bp + }; + + assert_eq!(1, breakpoints.len()); + assert_breakpoint( + &breakpoints, + &abs_path, + vec![ + (0, disable_breakpoint.clone()), + (2, Breakpoint::new_standard()), + (3, disable_breakpoint.clone()), + ], + ); + + editor.update_in(cx, |editor, window, cx| { + editor.move_to_beginning(&MoveToBeginning, window, cx); + editor.enable_breakpoint(&actions::EnableBreakpoint, window, cx); + editor.move_to_end(&MoveToEnd, window, cx); + editor.enable_breakpoint(&actions::EnableBreakpoint, window, cx); + editor.move_up(&MoveUp, window, cx); + editor.disable_breakpoint(&actions::DisableBreakpoint, window, cx); + }); + + let breakpoints = editor.update(cx, |editor, cx| { + editor + .breakpoint_store() + .as_ref() + .unwrap() + .read(cx) + .all_source_breakpoints(cx) + }); + + assert_eq!(1, breakpoints.len()); + assert_breakpoint( + &breakpoints, + &abs_path, + vec![ + (0, Breakpoint::new_standard()), + (2, disable_breakpoint), + (3, Breakpoint::new_standard()), + ], + ); +} + +struct BookmarkTestContext { + project: Entity, + editor: Entity, + cx: VisualTestContext, +} + +impl BookmarkTestContext { + async fn new(sample_text: &str, cx: &mut TestAppContext) -> BookmarkTestContext { + init_test(cx, |_| {}); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/a"), + json!({ + "main.rs": sample_text, + }), + ) + .await; + let project = Project::test(fs, [path!("/a").as_ref()], cx).await; + let window = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let mut visual_cx = VisualTestContext::from_window(*window, cx); + let worktree_id = workspace.update_in(&mut visual_cx, |workspace, _window, cx| { + workspace.project().update(cx, |project, cx| { + project.worktrees(cx).next().unwrap().read(cx).id() + }) + }); + + let buffer = project + .update(&mut visual_cx, |project, cx| { + project.open_buffer((worktree_id, rel_path("main.rs")), cx) + }) + .await + .unwrap(); + + let (editor, editor_cx) = cx.add_window_view(|window, cx| { + Editor::new( + EditorMode::full(), + MultiBuffer::build_from_buffer(buffer, cx), + Some(project.clone()), + window, + cx, + ) + }); + let cx = editor_cx.clone(); + + BookmarkTestContext { + project, + editor, + cx, + } + } + + fn abs_path(&self) -> Arc { + let project_path = self.editor.read_with(&self.cx, |editor, cx| { + editor.active_project_path(cx).unwrap() + }); + self.project.read_with(&self.cx, |project, cx| { + project + .absolute_path(&project_path, cx) + .map(Arc::from) + .unwrap() + }) + } + + fn all_bookmarks(&self) -> BTreeMap, Vec> { + self.project.read_with(&self.cx, |project, cx| { + project + .bookmark_store() + .read(cx) + .all_serialized_bookmarks(cx) + }) + } + + fn assert_bookmarked_file_count(&self, expected_count: usize) { + assert_eq!(expected_count, self.all_bookmarks().len()); + } + + fn assert_bookmark_rows(&self, expected_rows: Vec) { + let abs_path = self.abs_path(); + let bookmarks = self.all_bookmarks(); + if expected_rows.is_empty() { + assert!( + !bookmarks.contains_key(&abs_path), + "Expected no bookmarks for {}", + abs_path.display() + ); + } else { + let mut rows: Vec = bookmarks + .get(&abs_path) + .unwrap() + .iter() + .map(|b| b.row) + .collect(); + rows.sort(); + assert_eq!(expected_rows, rows); + } + } + + fn assert_bookmark_labels(&self, expected_labels: Vec<(u32, &str)>) { + let abs_path = self.abs_path(); + let bookmarks = self.all_bookmarks(); + let mut labels: Vec<(u32, &str)> = bookmarks + .get(&abs_path) + .unwrap() + .iter() + .map(|bookmark| (bookmark.row, bookmark.label.as_str())) + .collect(); + labels.sort_by_key(|(row, _)| *row); + assert_eq!(expected_labels, labels); + } + + fn confirm_action_available(&mut self) -> bool { + self.cx + .update(|window, cx| window.is_action_available(&menu::Confirm, cx)) + } + + fn select_rows(&mut self, rows: &[u32]) { + assert!(!rows.is_empty(), "expected at least one row to select"); + + self.editor + .update_in(&mut self.cx, |editor: &mut Editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| { + selections.select_ranges( + rows.iter() + .copied() + .map(|row| Point::new(row, 0)..Point::new(row, 0)), + ) + }); + }); + } + + fn prompt_blocks(&mut self) -> Vec<(DisplayRow, u32)> { + self.editor.update(&mut self.cx, |editor, cx| { + let snapshot = editor.display_snapshot(cx); + let max_row = snapshot.max_point().row().next_row(); + + snapshot + .blocks_in_range(DisplayRow(0)..max_row) + .filter_map(|(row, block)| match block { + crate::display_map::Block::Custom(_) => Some((row, block.height())), + _ => None, + }) + .collect() + }) + } + + fn assert_prompt_block_count(&mut self, expected_count: usize) { + assert_eq!(expected_count, self.prompt_blocks().len()); + } + + fn draw_window(&mut self) { + self.cx.update(|window, cx| { + window.refresh(); + let _ = window.draw(cx); + }); + } + + fn focus_bookmark_prompt_block(&mut self, block_index: usize) { + self.draw_window(); + + let prompt_blocks = self.prompt_blocks(); + let (block_row, block_height) = *prompt_blocks + .get(block_index) + .expect("expected bookmark prompt block"); + + let click_position = + self.editor + .update_in(&mut self.cx, |editor: &mut Editor, window, cx| { + let snapshot = editor.snapshot(window, cx); + let block_top = DisplayPoint::new(block_row, 0); + let relative_block_top = editor + .display_to_pixel_point(block_top, &snapshot, window, cx) + .expect("expected prompt block to be visible"); + let line_height = editor + .style(cx) + .text + .line_height_in_pixels(window.rem_size()); + let editor_origin = editor + .last_position_map + .as_ref() + .expect("expected editor position map") + .text_hitbox + .bounds + .origin; + let editor_center_x = editor + .last_bounds + .expect("expected editor bounds") + .center() + .x; + + gpui::Point { + x: editor_center_x, + y: editor_origin.y + + relative_block_top.y + + line_height * (block_height as f32 / 2.), + } + }); + + self.cx + .simulate_click(click_position, gpui::Modifiers::none()); + self.cx.run_until_parked(); + + assert!( + self.confirm_action_available(), + "expected bookmark prompt block to be focused" + ); + } + + fn confirm_bookmark_prompt_at_block_index(&mut self, block_index: usize, label: &str) { + // Confirming a PromptEditor returns focus to the parent editor, so each remaining + // prompt block must be focused explicitly before typing into it. + self.focus_bookmark_prompt_block(block_index); + self.confirm_bookmark_prompt(label); + } + + fn cursor_row(&mut self) -> u32 { + self.cursor_point().row + } + + fn cursor_point(&mut self) -> Point { + self.editor.update(&mut self.cx, |editor, cx| { + let snapshot = editor.display_snapshot(cx); + editor.selections.newest::(&snapshot).head() + }) + } + + fn move_to_row(&mut self, row: u32) { + self.editor + .update_in(&mut self.cx, |editor: &mut Editor, window, cx| { + editor.move_to_beginning(&MoveToBeginning, window, cx); + for _ in 0..row { + editor.move_down(&MoveDown, window, cx); + } + }); + } + + fn toggle_bookmark(&mut self) { + self.editor + .update_in(&mut self.cx, |editor: &mut Editor, window, cx| { + editor.toggle_bookmark(&actions::ToggleBookmark, window, cx); + }); + } + + fn toggle_bookmark_with_label(&mut self) { + self.editor + .update_in(&mut self.cx, |editor: &mut Editor, window, cx| { + editor.toggle_bookmark_with_label(&actions::ToggleBookmarkWithLabel, window, cx); + }); + } + + fn confirm_bookmark_prompt(&mut self, label: &str) { + if !label.is_empty() { + self.cx.simulate_input(label); + } + self.cx.dispatch_action(menu::Confirm); + self.cx.run_until_parked(); + } + + fn add_bookmark_with_label(&mut self, label: &str) { + self.toggle_bookmark_with_label(); + self.confirm_bookmark_prompt(label); + } + + fn toggle_bookmarks_at_rows(&mut self, rows: &[u32]) { + for &row in rows { + self.move_to_row(row); + self.add_bookmark_with_label(""); + } + } + + fn go_to_next_bookmark(&mut self) { + self.editor + .update_in(&mut self.cx, |editor: &mut Editor, window, cx| { + editor.go_to_next_bookmark(&actions::GoToNextBookmark, window, cx); + }); + } + + fn go_to_previous_bookmark(&mut self) { + self.editor + .update_in(&mut self.cx, |editor: &mut Editor, window, cx| { + editor.go_to_previous_bookmark(&actions::GoToPreviousBookmark, window, cx); + }); + } +} + +#[gpui::test] +async fn test_bookmark_toggling(cx: &mut TestAppContext) { + let mut ctx = + BookmarkTestContext::new("First line\nSecond line\nThird line\nFourth line", cx).await; + + ctx.add_bookmark_with_label(""); + ctx.editor + .update_in(&mut ctx.cx, |editor: &mut Editor, window, cx| { + editor.move_to_end(&MoveToEnd, window, cx); + }); + ctx.add_bookmark_with_label(""); + + ctx.assert_bookmarked_file_count(1); + ctx.assert_bookmark_rows(vec![0, 3]); + + ctx.move_to_row(0); + ctx.toggle_bookmark(); + + ctx.assert_bookmarked_file_count(1); + ctx.assert_bookmark_rows(vec![3]); + + ctx.move_to_row(3); + ctx.toggle_bookmark(); + + ctx.assert_bookmarked_file_count(0); + ctx.assert_bookmark_rows(vec![]); +} + +#[gpui::test] +async fn test_bookmark_toggling_unnamed_with_multiple_selections(cx: &mut TestAppContext) { + let mut ctx = + BookmarkTestContext::new("First line\nSecond line\nThird line\nFourth line", cx).await; + + ctx.select_rows(&[0, 1, 2]); + ctx.toggle_bookmark(); + + ctx.assert_prompt_block_count(0); + ctx.assert_bookmarked_file_count(1); + ctx.assert_bookmark_labels(vec![(0, ""), (1, ""), (2, "")]); + + ctx.select_rows(&[0, 1, 2, 3]); + ctx.toggle_bookmark(); + + ctx.assert_prompt_block_count(0); + ctx.assert_bookmark_rows(vec![0, 1, 2, 3]); + + ctx.select_rows(&[0, 1, 2, 3]); + ctx.toggle_bookmark(); + + ctx.assert_prompt_block_count(0); + ctx.assert_bookmarked_file_count(0); + ctx.assert_bookmark_rows(vec![]); +} + +#[gpui::test] +async fn test_bookmark_toggling_with_label_with_multiple_selections(cx: &mut TestAppContext) { + let mut ctx = + BookmarkTestContext::new("First line\nSecond line\nThird line\nFourth line", cx).await; + + ctx.select_rows(&[0, 1, 2]); + ctx.toggle_bookmark_with_label(); + + ctx.assert_prompt_block_count(3); + ctx.assert_bookmarked_file_count(0); + + ctx.confirm_bookmark_prompt_at_block_index(0, "first label"); + ctx.assert_prompt_block_count(2); + ctx.confirm_bookmark_prompt_at_block_index(0, "second label"); + ctx.assert_prompt_block_count(1); + ctx.confirm_bookmark_prompt_at_block_index(0, "third label"); + ctx.assert_prompt_block_count(0); + + ctx.assert_bookmarked_file_count(1); + ctx.assert_bookmark_labels(vec![ + (0, "first label"), + (1, "second label"), + (2, "third label"), + ]); + + ctx.select_rows(&[0, 1, 2, 3]); + ctx.toggle_bookmark_with_label(); + + ctx.assert_prompt_block_count(1); + ctx.assert_bookmark_labels(vec![ + (0, "first label"), + (1, "second label"), + (2, "third label"), + ]); + + ctx.confirm_bookmark_prompt_at_block_index(0, "fourth label"); + + ctx.assert_prompt_block_count(0); + ctx.assert_bookmark_labels(vec![ + (0, "first label"), + (1, "second label"), + (2, "third label"), + (3, "fourth label"), + ]); + + ctx.select_rows(&[0, 1, 2, 3]); + ctx.toggle_bookmark_with_label(); + + ctx.assert_prompt_block_count(0); + ctx.assert_bookmarked_file_count(0); + ctx.assert_bookmark_rows(vec![]); +} + +#[gpui::test] +async fn test_bookmark_toggle_deduplicates_by_row(cx: &mut TestAppContext) { + let mut ctx = + BookmarkTestContext::new("First line\nSecond line\nThird line\nFourth line", cx).await; + + ctx.editor + .update_in(&mut ctx.cx, |editor: &mut Editor, window, cx| { + editor.move_to_beginning(&MoveToBeginning, window, cx); + }); + ctx.add_bookmark_with_label(""); + + ctx.assert_bookmark_rows(vec![0]); + + ctx.editor + .update_in(&mut ctx.cx, |editor: &mut Editor, window, cx| { + editor.move_to_end_of_line( + &MoveToEndOfLine { + stop_at_soft_wraps: true, + }, + window, + cx, + ); + }); + ctx.toggle_bookmark(); + + ctx.assert_bookmark_rows(vec![]); +} + +#[gpui::test] +async fn test_bookmark_survives_edits(cx: &mut TestAppContext) { + let mut ctx = + BookmarkTestContext::new("First line\nSecond line\nThird line\nFourth line", cx).await; + + ctx.move_to_row(2); + ctx.add_bookmark_with_label(""); + ctx.assert_bookmark_rows(vec![2]); + + ctx.editor + .update_in(&mut ctx.cx, |editor: &mut Editor, window, cx| { + editor.move_to_beginning(&MoveToBeginning, window, cx); + editor.newline(&Newline, window, cx); + }); + + ctx.assert_bookmark_rows(vec![3]); + + ctx.move_to_row(3); + ctx.toggle_bookmark(); + ctx.assert_bookmark_rows(vec![]); +} + +#[gpui::test] +async fn test_active_bookmarks(cx: &mut TestAppContext) { + let mut ctx = BookmarkTestContext::new( + "Line 0\nLine 1\nLine 2\nLine 3\nLine 4\nLine 5\nLine 6\nLine 7\nLine 8\nLine 9", + cx, + ) + .await; + + ctx.toggle_bookmarks_at_rows(&[1, 3, 5, 8]); + + let active = ctx + .editor + .update_in(&mut ctx.cx, |editor: &mut Editor, window, cx| { + editor.active_bookmarks(DisplayRow(0)..DisplayRow(10), window, cx) + }); + assert!(active.contains(&DisplayRow(1))); + assert!(active.contains(&DisplayRow(3))); + assert!(active.contains(&DisplayRow(5))); + assert!(active.contains(&DisplayRow(8))); + assert!(!active.contains(&DisplayRow(0))); + assert!(!active.contains(&DisplayRow(2))); + assert!(!active.contains(&DisplayRow(9))); + + let active = ctx + .editor + .update_in(&mut ctx.cx, |editor: &mut Editor, window, cx| { + editor.active_bookmarks(DisplayRow(2)..DisplayRow(6), window, cx) + }); + assert!(active.contains(&DisplayRow(3))); + assert!(active.contains(&DisplayRow(5))); + assert!(!active.contains(&DisplayRow(1))); + assert!(!active.contains(&DisplayRow(8))); +} + +#[gpui::test] +async fn test_clearing_bookmark_store_notifies_editor(cx: &mut TestAppContext) { + let mut ctx = BookmarkTestContext::new("Line 0\nLine 1\nLine 2\nLine 3", cx).await; + + ctx.toggle_bookmarks_at_rows(&[1, 3]); + + let active = ctx + .editor + .update_in(&mut ctx.cx, |editor: &mut Editor, window, cx| { + editor.active_bookmarks(DisplayRow(0)..DisplayRow(4), window, cx) + }); + assert!(active.contains(&DisplayRow(1))); + assert!(active.contains(&DisplayRow(3))); + + let editor = ctx.editor.clone(); + let editor_notified = Rc::new(RefCell::new(false)); + let _subscription = ctx.cx.update(|_, cx| { + cx.observe(&editor, { + let editor_notified = editor_notified.clone(); + move |_, _| *editor_notified.borrow_mut() = true + }) + }); + + // `workspace::ClearBookmarks` mutates only the store, so the editor's gutter + // repaint relies entirely on its observation of the bookmark store. + ctx.project.update(&mut ctx.cx, |project, cx| { + project.bookmark_store().update(cx, |bookmark_store, cx| { + bookmark_store.clear_bookmarks(cx); + }); + }); + ctx.cx.run_until_parked(); + + assert!( + *editor_notified.borrow(), + "clearing the bookmark store should notify the editor so its gutter repaints" + ); + let active = ctx + .editor + .update_in(&mut ctx.cx, |editor: &mut Editor, window, cx| { + editor.active_bookmarks(DisplayRow(0)..DisplayRow(4), window, cx) + }); + assert!(active.is_empty()); +} + +#[gpui::test] +async fn test_clear_bookmarks_action_repaints_workspace_editor(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/a"), + json!({ "main.rs": "Line 0\nLine 1\nLine 2\nLine 3" }), + ) + .await; + let project = Project::test(fs, [path!("/a").as_ref()], cx).await; + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let mut cx = VisualTestContext::from_window(*window, cx); + let workspace = window + .read_with(&mut cx, |mw, _| mw.workspace().clone()) + .unwrap(); + + let worktree_id = workspace.update_in(&mut cx, |workspace, _, cx| { + workspace.project().update(cx, |project, cx| { + project.worktrees(cx).next().unwrap().read(cx).id() + }) + }); + + let editor = workspace + .update_in(&mut cx, |workspace, window, cx| { + workspace.open_path((worktree_id, rel_path("main.rs")), None, true, window, cx) + }) + .await + .unwrap() + .downcast::() + .unwrap(); + cx.run_until_parked(); + + editor.update_in(&mut cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_ranges([Point::new(1, 0)..Point::new(1, 0)]) + }); + editor.toggle_bookmark(&actions::ToggleBookmark, window, cx); + }); + + let active = editor.update_in(&mut cx, |editor, window, cx| { + editor.active_bookmarks(DisplayRow(0)..DisplayRow(4), window, cx) + }); + assert!(active.contains(&DisplayRow(1))); + + // Drain the effects still pending from the toggle above, so that the only + // thing that can notify the editor below is the action dispatch. + cx.run_until_parked(); + + let editor_notified = Rc::new(RefCell::new(false)); + let _subscription = cx.update(|_, cx| { + cx.observe(&editor, { + let editor_notified = editor_notified.clone(); + move |_, _| *editor_notified.borrow_mut() = true + }) + }); + + cx.dispatch_action(workspace::ClearBookmarks); + cx.run_until_parked(); + + assert!( + *editor_notified.borrow(), + "workspace::ClearBookmarks should notify the editor so its gutter repaints" + ); + let active = editor.update_in(&mut cx, |editor, window, cx| { + editor.active_bookmarks(DisplayRow(0)..DisplayRow(4), window, cx) + }); + assert!(active.is_empty()); +} + +#[gpui::test] +async fn test_bookmark_not_available_in_single_line_editor(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let (editor, _cx) = cx.add_window_view(|window, cx| Editor::single_line(window, cx)); + + editor.update(cx, |editor, _cx| { + assert!( + editor.bookmark_store.is_none(), + "Single-line editors should not have a bookmark store" + ); + }); +} + +#[gpui::test] +async fn test_edit_bookmark_does_not_open_prompt_without_existing_bookmark( + cx: &mut TestAppContext, +) { + let mut ctx = + BookmarkTestContext::new("First line\nSecond line\nThird line\nFourth line", cx).await; + + assert!(!ctx.confirm_action_available()); + + ctx.editor + .update_in(&mut ctx.cx, |editor: &mut Editor, window, cx| { + editor.edit_bookmark(&actions::EditBookmark, window, cx); + }); + + assert!(!ctx.confirm_action_available()); + ctx.assert_bookmark_rows(vec![]); +} + +#[gpui::test] +async fn test_edit_bookmark_updates_label_after_confirmation(cx: &mut TestAppContext) { + let mut ctx = + BookmarkTestContext::new("First line\nSecond line\nThird line\nFourth line", cx).await; + + ctx.add_bookmark_with_label("old label"); + ctx.assert_bookmark_labels(vec![(0, "old label")]); + + ctx.editor + .update_in(&mut ctx.cx, |editor: &mut Editor, window, cx| { + editor.edit_bookmark(&actions::EditBookmark, window, cx); + }); + + assert!(ctx.confirm_action_available()); + ctx.cx.dispatch_action(SelectAll); + ctx.cx.simulate_input("new label"); + ctx.cx.dispatch_action(menu::Confirm); + + ctx.assert_bookmark_labels(vec![(0, "new label")]); +} + +#[gpui::test] +async fn test_bookmark_navigation_lands_at_column_zero(cx: &mut TestAppContext) { + let mut ctx = + BookmarkTestContext::new("First line\nSecond line\nThird line\nFourth line", cx).await; + + ctx.editor + .update_in(&mut ctx.cx, |editor: &mut Editor, window, cx| { + editor.move_to_beginning(&MoveToBeginning, window, cx); + editor.move_down(&MoveDown, window, cx); + editor.move_to_end_of_line( + &MoveToEndOfLine { + stop_at_soft_wraps: true, + }, + window, + cx, + ); + }); + + let column_before_toggle = ctx.cursor_point().column; + assert_eq!( + column_before_toggle, 11, + "Cursor should be at the 11th column before toggling bookmark, got column {column_before_toggle}" + ); + + ctx.add_bookmark_with_label(""); + + ctx.editor + .update_in(&mut ctx.cx, |editor: &mut Editor, window, cx| { + editor.move_to_beginning(&MoveToBeginning, window, cx); + }); + + ctx.go_to_next_bookmark(); + + let cursor = ctx.cursor_point(); + assert_eq!(cursor.row, 1, "Should navigate to the bookmarked row"); + assert_eq!( + cursor.column, 0, + "Bookmark navigation should always land at column 0" + ); +} + +#[gpui::test] +async fn test_bookmark_set_from_nonzero_column_toggles_off_from_column_zero( + cx: &mut TestAppContext, +) { + let mut ctx = + BookmarkTestContext::new("First line\nSecond line\nThird line\nFourth line", cx).await; + + ctx.editor + .update_in(&mut ctx.cx, |editor: &mut Editor, window, cx| { + editor.move_to_beginning(&MoveToBeginning, window, cx); + editor.move_down(&MoveDown, window, cx); + editor.move_to_end_of_line( + &MoveToEndOfLine { + stop_at_soft_wraps: true, + }, + window, + cx, + ); + }); + ctx.add_bookmark_with_label(""); + + ctx.assert_bookmark_rows(vec![1]); + + ctx.editor + .update_in(&mut ctx.cx, |editor: &mut Editor, window, cx| { + editor.move_to_beginning_of_line( + &MoveToBeginningOfLine { + stop_at_soft_wraps: true, + stop_at_indent: false, + }, + window, + cx, + ); + }); + ctx.toggle_bookmark(); + + ctx.assert_bookmark_rows(vec![]); +} + +#[gpui::test] +async fn test_go_to_next_bookmark(cx: &mut TestAppContext) { + let mut ctx = BookmarkTestContext::new( + "Line 0\nLine 1\nLine 2\nLine 3\nLine 4\nLine 5\nLine 6\nLine 7\nLine 8\nLine 9", + cx, + ) + .await; + + ctx.toggle_bookmarks_at_rows(&[2, 5, 8]); + + ctx.move_to_row(0); + + ctx.go_to_next_bookmark(); + assert_eq!( + ctx.cursor_row(), + 2, + "First next-bookmark should go to row 2" + ); + + ctx.go_to_next_bookmark(); + assert_eq!( + ctx.cursor_row(), + 5, + "Second next-bookmark should go to row 5" + ); + + ctx.go_to_next_bookmark(); + assert_eq!( + ctx.cursor_row(), + 8, + "Third next-bookmark should go to row 8" + ); + + ctx.go_to_next_bookmark(); + assert_eq!( + ctx.cursor_row(), + 2, + "Next-bookmark should wrap around to row 2" + ); +} + +#[gpui::test] +async fn test_go_to_previous_bookmark(cx: &mut TestAppContext) { + let mut ctx = BookmarkTestContext::new( + "Line 0\nLine 1\nLine 2\nLine 3\nLine 4\nLine 5\nLine 6\nLine 7\nLine 8\nLine 9", + cx, + ) + .await; + + ctx.toggle_bookmarks_at_rows(&[2, 5, 8]); + + ctx.editor + .update_in(&mut ctx.cx, |editor: &mut Editor, window, cx| { + editor.move_to_end(&MoveToEnd, window, cx); + }); + + ctx.go_to_previous_bookmark(); + assert_eq!( + ctx.cursor_row(), + 8, + "First prev-bookmark should go to row 8" + ); + + ctx.go_to_previous_bookmark(); + assert_eq!( + ctx.cursor_row(), + 5, + "Second prev-bookmark should go to row 5" + ); + + ctx.go_to_previous_bookmark(); + assert_eq!( + ctx.cursor_row(), + 2, + "Third prev-bookmark should go to row 2" + ); + + ctx.go_to_previous_bookmark(); + assert_eq!( + ctx.cursor_row(), + 8, + "Prev-bookmark should wrap around to row 8" + ); +} + +#[gpui::test] +async fn test_go_to_bookmark_when_cursor_on_bookmarked_line(cx: &mut TestAppContext) { + let mut ctx = BookmarkTestContext::new( + "Line 0\nLine 1\nLine 2\nLine 3\nLine 4\nLine 5\nLine 6\nLine 7\nLine 8\nLine 9", + cx, + ) + .await; + + ctx.toggle_bookmarks_at_rows(&[3, 7]); + + ctx.move_to_row(3); + + ctx.go_to_next_bookmark(); + assert_eq!( + ctx.cursor_row(), + 7, + "Next from bookmarked row 3 should go to row 7" + ); + + ctx.go_to_previous_bookmark(); + assert_eq!( + ctx.cursor_row(), + 3, + "Previous from bookmarked row 7 should go to row 3" + ); + + ctx.go_to_next_bookmark(); + assert_eq!(ctx.cursor_row(), 7, "Next from row 3 should go to row 7"); + + ctx.go_to_next_bookmark(); + assert_eq!(ctx.cursor_row(), 3, "Next from row 7 should wrap to row 3"); +} + +#[gpui::test] +async fn test_go_to_bookmark_with_out_of_order_bookmarks(cx: &mut TestAppContext) { + let mut ctx = BookmarkTestContext::new( + "Line 0\nLine 1\nLine 2\nLine 3\nLine 4\nLine 5\nLine 6\nLine 7\nLine 8\nLine 9", + cx, + ) + .await; + + ctx.toggle_bookmarks_at_rows(&[8, 1, 5]); + + ctx.move_to_row(0); + + ctx.go_to_next_bookmark(); + assert_eq!(ctx.cursor_row(), 1, "First next should go to row 1"); + + ctx.go_to_next_bookmark(); + assert_eq!(ctx.cursor_row(), 5, "Second next should go to row 5"); + + ctx.go_to_next_bookmark(); + assert_eq!(ctx.cursor_row(), 8, "Third next should go to row 8"); + + ctx.go_to_next_bookmark(); + assert_eq!(ctx.cursor_row(), 1, "Fourth next should wrap to row 1"); + + ctx.go_to_previous_bookmark(); + assert_eq!( + ctx.cursor_row(), + 8, + "Prev from row 1 should wrap around to row 8" + ); + + ctx.go_to_previous_bookmark(); + assert_eq!(ctx.cursor_row(), 5, "Prev from row 8 should go to row 5"); + + ctx.go_to_previous_bookmark(); + assert_eq!(ctx.cursor_row(), 1, "Prev from row 5 should go to row 1"); +} + +#[gpui::test] +async fn test_empty_rename_is_no_op(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let capabilities = lsp::ServerCapabilities { + rename_provider: Some(lsp::OneOf::Right(lsp::RenameOptions { + prepare_provider: Some(true), + work_done_progress_options: Default::default(), + })), + ..Default::default() + }; + let mut cx = EditorLspTestContext::new_rust(capabilities, cx).await; + + cx.set_state("let aˇbc = 1;"); + + let rename_request_count = Arc::new(AtomicUsize::new(0)); + let _rename_handler = cx.set_request_handler::({ + let rename_request_count = rename_request_count.clone(); + move |_, _, _| { + rename_request_count.fetch_add(1, atomic::Ordering::SeqCst); + async { Ok(None) } + } + }); + let mut prepare_rename_handler = cx + .set_request_handler::( + move |_, _, _| async move { + Ok(Some(lsp::PrepareRenameResponse::Range(lsp::Range { + start: lsp::Position { + line: 0, + character: 4, + }, + end: lsp::Position { + line: 0, + character: 7, + }, + }))) + }, + ); + + for new_name in ["", " "] { + let prepare_rename_task = cx + .update_editor(|editor, window, cx| editor.rename(&Rename, window, cx)) + .expect("Prepare rename was not started"); + prepare_rename_handler.next().await.unwrap(); + prepare_rename_task.await.expect("Prepare rename failed"); + + let rename_editor = cx.editor(|editor, _, _| { + editor + .pending_rename() + .expect("Rename should still be pending") + .editor + .clone() + }); + rename_editor.update_in(&mut cx.cx.cx, |editor, window, cx| { + editor.backspace(&Backspace, window, cx); + editor.insert(new_name, window, cx); + }); + assert_eq!( + cx.editor(|editor, _, cx| { + editor + .pending_rename() + .expect("Rename should still be pending") + .editor + .read(cx) + .text(cx) + }), + new_name + ); + + cx.update_editor(|editor, window, cx| { + editor + .confirm_rename(&ConfirmRename, window, cx) + .expect("Confirm rename should consume the action") + }) + .await + .expect("Confirm rename failed"); + + assert_eq!(rename_request_count.load(atomic::Ordering::SeqCst), 0); + assert!(cx.editor(|editor, _, _| editor.pending_rename().is_none())); + assert_eq!(cx.buffer_text(), "let abc = 1;"); + } +} + +#[gpui::test] +async fn test_bookmarks_tab_auto_refresh(cx: &mut TestAppContext) { + let (workspace, _pane, project, editor, mut cx) = init_bookmarks_tab_test( + cx, + json!({ + "main.rs": "line0\nline1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\n", + "other.rs": "other0\nother1\nother2\nother3\n", + }), + ) + .await; + + toggle_bookmark_on_row(&editor, 0, &mut cx); + cx.run_until_parked(); + + let bookmarks_editor = open_and_find_bookmarks_tab(&workspace, &mut cx); + + assert_eq!( + excerpt_count(&bookmarks_editor, &mut cx), + 1, + "bookmarks editor should have 1 excerpt after first bookmark" + ); + + toggle_bookmark_on_row(&editor, 7, &mut cx); + cx.run_until_parked(); + assert_eq!( + excerpt_count(&bookmarks_editor, &mut cx), + 2, + "bookmarks editor should auto-refresh to 2 excerpts after second bookmark" + ); + + toggle_bookmark_on_row(&editor, 7, &mut cx); + cx.run_until_parked(); + assert_eq!( + excerpt_count(&bookmarks_editor, &mut cx), + 1, + "bookmarks editor should auto-refresh back to 1 excerpt after removing a bookmark" + ); + assert_eq!( + cursor_line_text(&bookmarks_editor, &mut cx), + "line0", + "refreshing must keep the surviving excerpt intact, preserving selections in it" + ); + + let other_editor = open_bookmarks_test_editor(&workspace, &project, "other.rs", &mut cx).await; + toggle_bookmark_on_row(&other_editor, 1, &mut cx); + cx.run_until_parked(); + assert_eq!( + bookmarked_paths(&bookmarks_editor, &mut cx), + vec!["main.rs", "other.rs"], + "bookmarks editor should hold one path per bookmarked file" + ); + + toggle_bookmark_on_row(&editor, 0, &mut cx); + cx.run_until_parked(); + assert_eq!( + bookmarked_paths(&bookmarks_editor, &mut cx), + vec!["other.rs"], + "unbookmarking a file's last line should drop its path from the bookmarks editor" + ); +} + +#[gpui::test] +async fn test_bookmarks_tab_selects_first_bookmark_despite_concurrent_change( + cx: &mut TestAppContext, +) { + let (workspace, _pane, _project, editor, mut cx) = init_bookmarks_tab_test( + cx, + json!({ + "main.rs": "line0\nline1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\n", + }), + ) + .await; + + toggle_bookmark_on_row(&editor, 5, &mut cx); + cx.run_until_parked(); + + open_bookmarks_tab(&workspace, &mut cx); + + toggle_bookmark_on_row(&editor, 9, &mut cx); + cx.run_until_parked(); + + let bookmarks_editor = find_bookmarks_editor(&workspace, &mut cx) + .expect("bookmarks editor should exist after view_bookmarks"); + assert_eq!( + cursor_line_text(&bookmarks_editor, &mut cx), + "line5", + "a bookmark change racing the tab creation must not lose the initial selection" + ); +} + +#[gpui::test] +async fn test_view_bookmarks_without_resolvable_bookmarks_opens_no_tab(cx: &mut TestAppContext) { + let (workspace, _pane, project, _editor, mut cx) = init_bookmarks_tab_test( + cx, + json!({ + "main.rs": "line0\nline1\n", + "other.rs": "other0\n", + "third.rs": "third0\n", + }), + ) + .await; + + let bookmark_store = cx.update(|_window, cx| project.read(cx).bookmark_store()); + load_test_bookmarks( + &bookmark_store, + vec![(bookmark_path(path!("/a/third.rs")), vec![99])], + &mut cx, + ) + .await; + + let worktree_id = project.update(&mut cx, |project, cx| { + project + .worktrees(cx) + .next() + .expect("project should have at least one worktree") + .read(cx) + .id() + }); + let other_buffer = project + .update(&mut cx, |project, cx| { + project.open_buffer((worktree_id, rel_path("other.rs")), cx) + }) + .await + .expect("other.rs buffer should open"); + let probe_anchor = other_buffer.update(&mut cx, |buffer, _| { + buffer.text_snapshot().anchor_before(Point::new(0, 0)) + }); + bookmark_store.update(&mut cx, |store, cx| { + assert_eq!( + store + .find_bookmark(&other_buffer, probe_anchor, cx) + .map(|bookmark| bookmark.label.clone()), + None, + "a probe on a bookmark-less file should find nothing" + ); + }); + + open_bookmarks_tab(&workspace, &mut cx); + cx.run_until_parked(); + + assert_eq!( + find_bookmarks_editor(&workspace, &mut cx), + None, + "a store whose bookmarks all fail to resolve must not open an empty tab, even after a find_bookmark probe" + ); +} + +#[gpui::test] +async fn test_bookmarks_tab_survives_split(cx: &mut TestAppContext) { + let (workspace, pane, _project, editor, mut cx) = init_bookmarks_tab_test( + cx, + json!({ + "main.rs": "line0\nline1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\n", + }), + ) + .await; + + toggle_bookmark_on_row(&editor, 0, &mut cx); + cx.run_until_parked(); + + let bookmarks_editor = open_and_find_bookmarks_tab(&workspace, &mut cx); + let split_editor = bookmarks_editor.update_in(&mut cx, |editor, window, cx| { + cx.new(|cx| editor.clone(window, cx)) + }); + assert!( + split_editor.read_with(&cx, |editor, _| editor.bookmarks_tab_state.is_some()), + "split clone should share the bookmarks tab state" + ); + assert!( + split_editor.read_with(&cx, |editor, cx| editor + .highlighted_rows::(cx) + .next() + .is_some()), + "split clone should carry over the bookmark line highlights" + ); + + toggle_bookmark_on_row(&editor, 7, &mut cx); + close_bookmarks_editor(&pane, bookmarks_editor, &mut cx); + assert_eq!( + excerpt_count(&split_editor, &mut cx), + 2, + "split should refresh itself when the original tab closes with a refresh in flight" + ); + + toggle_bookmark_on_row(&editor, 7, &mut cx); + cx.run_until_parked(); + assert_eq!( + excerpt_count(&split_editor, &mut cx), + 1, + "split clone's subscription should keep refreshing after the original tab closes" + ); +} + +#[gpui::test] +async fn test_bookmark_resolution_drop_emits_store_event(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/a"), + json!({ + "main.rs": "line0\nline1\n", + }), + ) + .await; + let project = Project::test(fs, [path!("/a").as_ref()], cx).await; + let worktree_id = project.read_with(cx, |project, cx| { + project + .worktrees(cx) + .next() + .expect("project should have at least one worktree") + .read(cx) + .id() + }); + let buffer = project + .update(cx, |project, cx| { + project.open_buffer((worktree_id, rel_path("main.rs")), cx) + }) + .await + .expect("main.rs buffer should open"); + + let bookmark_store = project.read_with(cx, |project, _| project.bookmark_store()); + let abs_path = cx + .update(|cx| BookmarkStore::abs_path_from_buffer(&buffer, cx)) + .expect("buffer should have an absolute path"); + + bookmark_store + .update(cx, |store, cx| { + store.load_serialized_bookmarks( + BTreeMap::from([(abs_path, serialized_rows(&[0, 99]))]), + cx, + ) + }) + .await + .expect("loading serialized bookmarks should succeed"); + + let events = Rc::new(RefCell::new(Vec::::new())); + let _subscription = cx.update({ + let events = events.clone(); + |cx| { + cx.subscribe(&bookmark_store, move |_, event: &BookmarkStoreEvent, _| { + events.borrow_mut().push(event.clone()); + }) + } + }); + + let (buffer_id, snapshot) = + buffer.read_with(cx, |buffer, _| (buffer.remote_id(), buffer.text_snapshot())); + let resolved_count = bookmark_store.update(cx, |store, cx| { + store + .bookmarks_for_buffer( + buffer.clone(), + text::Anchor::min_max_range_for_buffer(buffer_id), + &snapshot, + cx, + ) + .len() + }); + assert_eq!( + resolved_count, 1, + "only the in-range bookmark should survive resolution" + ); + assert_eq!( + *events.borrow(), + vec![BookmarkStoreEvent::BookmarksChanged], + "dropping the out-of-range bookmark during resolution should emit BookmarksChanged" + ); + + let resolved_count = bookmark_store.update(cx, |store, cx| { + store + .bookmarks_for_buffer( + buffer.clone(), + text::Anchor::min_max_range_for_buffer(buffer_id), + &snapshot, + cx, + ) + .len() + }); + assert_eq!( + resolved_count, 1, + "an already-resolved entry should keep its bookmark" + ); + assert_eq!( + *events.borrow(), + vec![BookmarkStoreEvent::BookmarksChanged], + "resolution of an already-loaded entry should not emit again" + ); + + bookmark_store.update(cx, |store, cx| { + store.edit_bookmark( + &buffer, + snapshot.anchor_before(Point::new(0, 0)), + "renamed".to_string(), + cx, + ) + }); + assert_eq!( + *events.borrow(), + vec![ + BookmarkStoreEvent::BookmarksChanged, + BookmarkStoreEvent::LabelChanged + ], + "editing a bookmark's label should emit LabelChanged rather than a set change" + ); +} + +#[gpui::test] +async fn test_bookmarks_tab_highlights_bookmark_on_trailing_empty_line(cx: &mut TestAppContext) { + let (workspace, _pane, _project, editor, mut cx) = init_bookmarks_tab_test( + cx, + json!({ + "main.rs": "line0\nline1\n", + }), + ) + .await; + + toggle_bookmark_on_row(&editor, 2, &mut cx); + cx.run_until_parked(); + + let bookmarks_editor = open_and_find_bookmarks_tab(&workspace, &mut cx); + assert_eq!( + highlighted_display_rows_of(&bookmarks_editor, &mut cx), + vec![last_display_row(&bookmarks_editor, &mut cx)], + "a bookmark on the trailing empty line should still highlight its row" + ); +} + +#[gpui::test] +async fn test_expand_excerpts_in_bookmarks_tab_with_trailing_empty_excerpt( + cx: &mut TestAppContext, +) { + let (workspace, _pane, project, editor, mut cx) = + init_bookmarks_tab_test(cx, json!({ "main.rs": "aaa", "other.rs": "zero\none\n" })).await; + cx.update(|_, cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings.editor.excerpt_context_lines = Some(0); + }); + }); + }); + toggle_bookmark_on_row(&editor, 0, &mut cx); + let other_editor = open_bookmarks_test_editor(&workspace, &project, "other.rs", &mut cx).await; + toggle_bookmark_on_row(&other_editor, 2, &mut cx); + + let bookmarks_editor = open_and_find_bookmarks_tab(&workspace, &mut cx); + assert_eq!( + bookmarks_editor.read_with(&cx, |editor, cx| editor.text(cx)), + "aaa\n" + ); + cx.dispatch_action(SelectAll); + cx.dispatch_action(ExpandExcerptsUp { lines: 1 }); + cx.run_until_parked(); + assert_eq!( + bookmarks_editor.read_with(&cx, |editor, cx| editor.text(cx)), + "aaa\none\n" + ); +} + +#[gpui::test] +async fn test_bookmarks_tab_keeps_unopenable_paths(cx: &mut TestAppContext) { + let (workspace, _pane, project, editor, mut cx) = init_bookmarks_tab_test( + cx, + json!({ + "main.rs": "line0\nline1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\n", + "unopenable.rs": {}, + }), + ) + .await; + + let bookmark_store = cx.update(|_window, cx| project.read(cx).bookmark_store()); + let unopenable_path = bookmark_path(path!("/a/unopenable.rs")); + load_test_bookmarks( + &bookmark_store, + vec![ + (bookmark_path(path!("/a/main.rs")), vec![0]), + (Arc::clone(&unopenable_path), vec![0]), + ], + &mut cx, + ) + .await; + + let bookmarks_editor = open_and_find_bookmarks_tab(&workspace, &mut cx); + assert_eq!( + excerpt_count(&bookmarks_editor, &mut cx), + 1, + "only the openable file should produce an excerpt" + ); + assert_eq!( + failed_paths(&bookmark_store, &mut cx), + vec![Arc::clone(&unopenable_path)], + "the unopenable path should be recorded to avoid retrying it on every refresh" + ); + + toggle_bookmark_on_row(&editor, 7, &mut cx); + cx.run_until_parked(); + assert_eq!( + excerpt_count(&bookmarks_editor, &mut cx), + 2, + "refreshes should keep working while an unopenable path is recorded" + ); + + let serialized = bookmark_store.read_with(&cx, |store, cx| store.all_serialized_bookmarks(cx)); + assert_eq!( + serialized.get(&unopenable_path).map(|rows| rows.len()), + Some(1), + "an unopenable path must keep its serialized bookmark instead of losing it" + ); +} + +#[gpui::test] +async fn test_view_bookmarks_activates_most_recent_tab(cx: &mut TestAppContext) { + let (workspace, pane, _project, editor, mut cx) = init_bookmarks_tab_test( + cx, + json!({ + "main.rs": "line0\nline1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\n", + }), + ) + .await; + + toggle_bookmark_on_row(&editor, 0, &mut cx); + cx.run_until_parked(); + + let bookmarks_editor = open_and_find_bookmarks_tab(&workspace, &mut cx); + let split_editor = bookmarks_editor.update_in(&mut cx, |editor, window, cx| { + cx.new(|cx| editor.clone(window, cx)) + }); + let second_pane = workspace.update_in(&mut cx, |workspace, window, cx| { + workspace.split_pane(pane.clone(), SplitDirection::Right, window, cx) + }); + second_pane.update_in(&mut cx, |pane, window, cx| { + pane.add_item(Box::new(split_editor.clone()), true, true, None, window, cx); + }); + cx.run_until_parked(); + + open_bookmarks_tab(&workspace, &mut cx); + cx.run_until_parked(); + + let active_editor = workspace.update(&mut cx, |workspace, cx| { + workspace.active_item_as::(cx) + }); + assert_eq!( + active_editor, + Some(split_editor), + "view_bookmarks should activate the most recently used bookmarks tab" + ); + let bookmark_editor_count = workspace.update(&mut cx, |workspace, cx| { + workspace + .items_of_type::(cx) + .filter(|editor| editor.read(cx).bookmarks_tab_state.is_some()) + .count() + }); + assert_eq!( + bookmark_editor_count, 2, + "activating an existing tab must not create another one" + ); +} + +#[gpui::test] +async fn test_bookmarks_tab_highlight_does_not_grow_past_last_row(cx: &mut TestAppContext) { + let (workspace, _pane, _project, editor, mut cx) = init_bookmarks_tab_test( + cx, + json!({ + "main.rs": "line0\nline1\nline2", + }), + ) + .await; + + toggle_bookmark_on_row(&editor, 2, &mut cx); + cx.run_until_parked(); + + let bookmarks_editor = open_and_find_bookmarks_tab(&workspace, &mut cx); + + editor.update_in(&mut cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_ranges([Point::new(2, 5)..Point::new(2, 5)]) + }); + editor.insert("\nline3", window, cx); + }); + cx.run_until_parked(); + + assert_eq!( + highlighted_display_rows_of(&bookmarks_editor, &mut cx), + vec![display_row_for( + &bookmarks_editor, + Point::new(2, 0), + &mut cx + )], + "appending lines after a last-row bookmark must not stretch its highlight over the new rows" + ); +} + +#[gpui::test] +async fn test_bookmarks_tab_trailing_line_highlight_does_not_grow_on_edit(cx: &mut TestAppContext) { + let (workspace, _pane, _project, editor, mut cx) = init_bookmarks_tab_test( + cx, + json!({ + "main.rs": "line0\nline1\n", + }), + ) + .await; + + toggle_bookmark_on_row(&editor, 2, &mut cx); + cx.run_until_parked(); + + let bookmarks_editor = open_and_find_bookmarks_tab(&workspace, &mut cx); + + bookmarks_editor.update_in(&mut cx, |editor, window, cx| { + let max_point = editor.buffer.read(cx).snapshot(cx).max_point(); + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_ranges([max_point..max_point]) + }); + editor.insert("new0\nnew1", window, cx); + }); + cx.run_until_parked(); + + assert_eq!( + highlighted_display_rows_of(&bookmarks_editor, &mut cx), + vec![last_display_row(&bookmarks_editor, &mut cx)], + "typing at a trailing-line bookmark must move its single-row highlight, not stretch it" + ); +} + +#[gpui::test] +async fn test_bookmarks_tab_last_line_highlight_does_not_cover_next_file_header( + cx: &mut TestAppContext, +) { + let (workspace, _pane, project, editor, mut cx) = init_bookmarks_tab_test( + cx, + json!({ + "main.rs": "line0\nline1\nline2", + "other.rs": "other0\nother1\n", + }), + ) + .await; + + toggle_bookmark_on_row(&editor, 2, &mut cx); + let other_editor = open_bookmarks_test_editor(&workspace, &project, "other.rs", &mut cx).await; + toggle_bookmark_on_row(&other_editor, 0, &mut cx); + cx.run_until_parked(); + + let bookmarks_editor = open_and_find_bookmarks_tab(&workspace, &mut cx); + assert_eq!( + highlighted_display_rows_of(&bookmarks_editor, &mut cx), + vec![ + display_row_for(&bookmarks_editor, Point::new(2, 0), &mut cx), + display_row_for(&bookmarks_editor, Point::new(3, 0), &mut cx), + ], + "a bookmark on a file's last line must not highlight the next file's header rows" + ); +} + +#[gpui::test] +async fn test_bookmarks_tab_trailing_line_highlight_does_not_cover_next_file_header( + cx: &mut TestAppContext, +) { + let (workspace, _pane, project, editor, mut cx) = init_bookmarks_tab_test( + cx, + json!({ + "main.rs": "line0\nline1\n", + "other.rs": "other0\n", + }), + ) + .await; + + toggle_bookmark_on_row(&editor, 2, &mut cx); + let other_editor = open_bookmarks_test_editor(&workspace, &project, "other.rs", &mut cx).await; + toggle_bookmark_on_row(&other_editor, 0, &mut cx); + cx.run_until_parked(); + + let bookmarks_editor = open_and_find_bookmarks_tab(&workspace, &mut cx); + assert_eq!( + highlighted_display_rows_of(&bookmarks_editor, &mut cx), + vec![ + display_row_for(&bookmarks_editor, Point::new(2, 0), &mut cx), + display_row_for(&bookmarks_editor, Point::new(3, 0), &mut cx), + ], + "a bookmark on a trailing empty line must not highlight the next file's header rows" + ); +} + +#[gpui::test] +async fn test_worktree_add_retries_only_contained_failed_paths(cx: &mut TestAppContext) { + let (workspace, _pane, project, _editor, mut cx) = init_bookmarks_tab_test( + cx, + json!({ + "main.rs": "line0\nline1\n", + }), + ) + .await; + + let bookmark_store = cx.update(|_window, cx| project.read(cx).bookmark_store()); + let elsewhere_path = bookmark_path(path!("/elsewhere/file.rs")); + load_test_bookmarks( + &bookmark_store, + vec![ + (bookmark_path(path!("/a/main.rs")), vec![0]), + (Arc::clone(&elsewhere_path), vec![0]), + ], + &mut cx, + ) + .await; + + let bookmarks_editor = open_and_find_bookmarks_tab(&workspace, &mut cx); + assert_eq!( + excerpt_count(&bookmarks_editor, &mut cx), + 1, + "the path outside all worktrees should not produce an excerpt yet" + ); + assert_eq!( + failed_paths(&bookmark_store, &mut cx), + vec![Arc::clone(&elsewhere_path)], + "the path outside all worktrees should be recorded as failed to open" + ); + + let events = Rc::new(RefCell::new(Vec::::new())); + let _subscription = cx.update({ + let events = events.clone(); + |_window, cx| { + cx.subscribe(&bookmark_store, move |_, event: &BookmarkStoreEvent, _| { + events.borrow_mut().push(event.clone()); + }) + } + }); + + let fs = project.read_with(&cx, |project, _| project.fs().clone()); + fs.as_fake() + .insert_tree(path!("/unrelated"), json!({ "other.rs": "other0\n" })) + .await; + project + .update(&mut cx, |project, cx| { + project.create_worktree(path!("/unrelated"), true, cx) + }) + .await + .expect("creating the unrelated worktree should succeed"); + cx.run_until_parked(); + + assert_eq!( + *events.borrow(), + Vec::::new(), + "a worktree containing no failed paths must not trigger a retry" + ); + assert_eq!( + failed_paths(&bookmark_store, &mut cx), + vec![Arc::clone(&elsewhere_path)], + "an unrelated worktree must not clear the failed path cache" + ); + + fs.as_fake() + .insert_tree(path!("/elsewhere"), json!({ "file.rs": "else0\n" })) + .await; + project + .update(&mut cx, |project, cx| { + project.create_worktree(path!("/elsewhere"), true, cx) + }) + .await + .expect("creating the containing worktree should succeed"); + cx.run_until_parked(); + + assert_eq!( + failed_paths(&bookmark_store, &mut cx), + Vec::new(), + "adding a worktree containing a failed path must retry it" + ); + assert_eq!( + excerpt_count(&bookmarks_editor, &mut cx), + 2, + "the bookmarks tab should refresh and show the failed path's bookmark" + ); +} + +#[gpui::test] +async fn test_bookmarks_tab_retries_failed_path_when_file_appears(cx: &mut TestAppContext) { + let (workspace, _pane, project, _editor, mut cx) = init_bookmarks_tab_test( + cx, + json!({ + "main.rs": "line0\nline1\n", + "late.rs": {}, + }), + ) + .await; + + let bookmark_store = cx.update(|_window, cx| project.read(cx).bookmark_store()); + let late_path = bookmark_path(path!("/a/late.rs")); + load_test_bookmarks( + &bookmark_store, + vec![ + (bookmark_path(path!("/a/main.rs")), vec![0]), + (Arc::clone(&late_path), vec![0]), + ], + &mut cx, + ) + .await; + + let bookmarks_editor = open_and_find_bookmarks_tab(&workspace, &mut cx); + assert_eq!( + excerpt_count(&bookmarks_editor, &mut cx), + 1, + "the missing file should not produce an excerpt yet" + ); + assert_eq!( + failed_paths(&bookmark_store, &mut cx), + vec![Arc::clone(&late_path)], + "the missing path should be recorded as failed to open" + ); + + let fs = project.read_with(&cx, |project, _| project.fs().clone()); + fs.remove_dir( + std::path::Path::new(path!("/a/late.rs")), + fs::RemoveOptions { + recursive: true, + ignore_if_not_exists: false, + }, + ) + .await + .expect("removing the placeholder directory should succeed"); + fs.as_fake() + .insert_file(path!("/a/late.rs"), "late0\n".as_bytes().to_vec()) + .await; + cx.run_until_parked(); + + assert_eq!( + failed_paths(&bookmark_store, &mut cx), + Vec::new(), + "a failed path must be retried once its file appears on disk" + ); + assert_eq!( + excerpt_count(&bookmarks_editor, &mut cx), + 2, + "the bookmarks tab should refresh and show the late file's bookmark" + ); +} + +#[gpui::test] +async fn test_dynamic_document_highlight_registration_refreshes_editor(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorLspTestContext::new_rust(lsp::ServerCapabilities::default(), cx).await; + let debounce = Duration::from_millis( + cx.update(|_, cx| EditorSettings::get_global(cx).lsp_highlight_debounce.0), + ); + let project = cx.update_workspace(|workspace, _, _| workspace.project().clone()); + let server_id = cx.lsp.server.server_id(); + cx.set_state(indoc! {" + fn main() { + let foo = 1; + fˇoo; + } + "}); + cx.executor().advance_clock(debounce); + cx.run_until_parked(); + + let request_count = Arc::new(AtomicUsize::new(0)); + let _request_handler = + cx.set_request_handler::({ + let request_count = request_count.clone(); + move |_, _, _| { + request_count.fetch_add(1, atomic::Ordering::SeqCst); + async move { + Ok(Some(vec![lsp::DocumentHighlight { + range: lsp::Range::new(lsp::Position::new(2, 4), lsp::Position::new(2, 7)), + kind: Some(lsp::DocumentHighlightKind::READ), + }])) + } + } + }); + + register_document_highlight_capability(&cx.lsp, "python-document-highlight", "python").await; + cx.executor().advance_clock(debounce); + cx.run_until_parked(); + assert_eq!( + request_count.load(atomic::Ordering::SeqCst), + 0, + "expected a registration for another language to not query the server", + ); + assert_eq!(document_highlight_count(&mut cx), 0); + + register_document_highlight_capability(&cx.lsp, "rust-document-highlight", "rust").await; + cx.executor().advance_clock(debounce); + cx.run_until_parked(); + assert_eq!( + request_count.load(atomic::Ordering::SeqCst), + 1, + "expected an applicable registration to refresh document highlights", + ); + assert_eq!(document_highlight_count(&mut cx), 1); + + cx.update_editor(|editor, _, cx| { + editor.refresh_document_highlights(cx); + }); + for _ in 0..3 { + cx.executor().advance_clock(debounce / 4); + cx.run_until_parked(); + cx.update(|_, cx| { + project.update(cx, |_, cx| { + cx.emit(project::Event::RefreshDocumentHighlights { + server_id: Some(LanguageServerId(server_id.0 + 1)), + }); + }); + }); + cx.run_until_parked(); + } + cx.executor().advance_clock(debounce / 4); + cx.run_until_parked(); + assert_eq!( + request_count.load(atomic::Ordering::SeqCst), + 2, + "expected refreshes of servers unrelated to the buffer to not restart the debounce", + ); + + cx.update(|_, cx| { + project.update(cx, |_, cx| { + cx.emit(project::Event::RefreshDocumentHighlights { + server_id: Some(server_id), + }); + }); + }); + cx.executor().advance_clock(debounce); + cx.run_until_parked(); + assert_eq!( + request_count.load(atomic::Ordering::SeqCst), + 3, + "expected a refresh of the buffer's server to query document highlights", + ); + + cx.lsp + .request::( + lsp::UnregistrationParams { + unregisterations: vec![lsp::Unregistration { + id: "rust-document-highlight".to_string(), + method: "textDocument/documentHighlight".to_string(), + }], + }, + DEFAULT_LSP_REQUEST_TIMEOUT, + ) + .await + .into_response() + .unwrap(); + cx.executor().advance_clock(debounce); + cx.run_until_parked(); + assert_eq!( + request_count.load(atomic::Ordering::SeqCst), + 3, + "expected no server query after the applicable registration is removed", + ); + assert_eq!( + document_highlight_count(&mut cx), + 0, + "expected stale document highlights to be cleared after unregistration", + ); + + register_document_highlight_capability(&cx.lsp, "rust-document-highlight", "rust").await; + cx.executor().advance_clock(debounce); + cx.run_until_parked(); + assert_eq!(request_count.load(atomic::Ordering::SeqCst), 4); + assert_eq!(document_highlight_count(&mut cx), 1); + + let buffer = cx.update_editor(|editor, _, cx| editor.buffer().read(cx).as_singleton().unwrap()); + cx.update(|_, cx| { + project.update(cx, |project, cx| { + project.stop_language_servers_for_buffers(vec![buffer], HashSet::default(), cx); + }); + }); + cx.executor().advance_clock(debounce); + cx.run_until_parked(); + assert_eq!( + request_count.load(atomic::Ordering::SeqCst), + 4, + "expected no document highlight request after the server is stopped", + ); + assert_eq!( + document_highlight_count(&mut cx), + 0, + "expected stale document highlights to be cleared after the server is stopped", + ); +} + +#[gpui::test] +async fn test_rename_with_duplicate_edits(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let capabilities = lsp::ServerCapabilities { + rename_provider: Some(lsp::OneOf::Right(lsp::RenameOptions { + prepare_provider: Some(true), + work_done_progress_options: Default::default(), + })), + ..Default::default() + }; + let mut cx = EditorLspTestContext::new_rust(capabilities, cx).await; + + cx.set_state(indoc! {" + struct Fˇoo {} + "}); + + cx.update_editor(|editor, _, cx| { + let highlight_range = Point::new(0, 7)..Point::new(0, 10); + let highlight_range = highlight_range.to_anchors(&editor.buffer().read(cx).snapshot(cx)); + editor.highlight_background( + HighlightKey::DocumentHighlightRead, + &[highlight_range], + |_, theme| theme.colors().editor_document_highlight_read_background, + cx, + ); + }); + + let mut prepare_rename_handler = cx + .set_request_handler::( + move |_, _, _| async move { + Ok(Some(lsp::PrepareRenameResponse::Range(lsp::Range { + start: lsp::Position { + line: 0, + character: 7, + }, + end: lsp::Position { + line: 0, + character: 10, + }, + }))) + }, + ); + let prepare_rename_task = cx + .update_editor(|e, window, cx| e.rename(&Rename, window, cx)) + .expect("Prepare rename was not started"); + prepare_rename_handler.next().await.unwrap(); + prepare_rename_task.await.expect("Prepare rename failed"); + + let mut rename_handler = + cx.set_request_handler::(move |url, _, _| async move { + let edit = lsp::TextEdit { + range: lsp::Range { + start: lsp::Position { + line: 0, + character: 7, + }, + end: lsp::Position { + line: 0, + character: 10, + }, + }, + new_text: "FooRenamed".to_string(), + }; + Ok(Some(lsp::WorkspaceEdit::new( + // Specify the same edit twice + std::collections::HashMap::from_iter(Some((url, vec![edit.clone(), edit]))), + ))) + }); + let rename_task = cx + .update_editor(|e, window, cx| e.confirm_rename(&ConfirmRename, window, cx)) + .expect("Confirm rename was not started"); + rename_handler.next().await.unwrap(); + rename_task.await.expect("Confirm rename failed"); + cx.run_until_parked(); + + // Despite two edits, only one is actually applied as those are identical + cx.assert_editor_state(indoc! {" + struct FooRenamedˇ {} + "}); +} + +#[gpui::test] +async fn test_rename_with_out_of_order_document_highlights(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let capabilities = lsp::ServerCapabilities { + rename_provider: Some(lsp::OneOf::Right(lsp::RenameOptions { + prepare_provider: Some(true), + work_done_progress_options: Default::default(), + })), + ..Default::default() + }; + let mut cx = EditorLspTestContext::new_rust(capabilities, cx).await; + + cx.set_state(indoc! {" + struct Foo {} + fn main() { + let first = Foo {}; + let second = Fˇoo {}; + } + "}); + + cx.update_editor(|editor, _window, cx| { + let snapshot = editor.buffer().read(cx).snapshot(cx); + let read_highlight = (Point::new(2, 16)..Point::new(2, 19)).to_anchors(&snapshot); + let write_highlight = (Point::new(3, 17)..Point::new(3, 20)).to_anchors(&snapshot); + editor.highlight_background( + HighlightKey::DocumentHighlightRead, + &[read_highlight], + |_, theme| theme.colors().editor_document_highlight_read_background, + cx, + ); + editor.highlight_background( + HighlightKey::DocumentHighlightWrite, + &[write_highlight], + |_, theme| theme.colors().editor_document_highlight_write_background, + cx, + ); + }); + + let mut prepare_rename_handler = cx + .set_request_handler::( + move |_, _, _| async move { + Ok(Some(lsp::PrepareRenameResponse::Range(lsp::Range { + start: lsp::Position { + line: 3, + character: 17, + }, + end: lsp::Position { + line: 3, + character: 20, + }, + }))) + }, + ); + let prepare_rename_task = cx + .update_editor(|e, window, cx| e.rename(&Rename, window, cx)) + .expect("Prepare rename was not started"); + prepare_rename_handler.next().await.unwrap(); + prepare_rename_task.await.expect("Prepare rename failed"); + + cx.update_editor(|editor, window, cx| { + editor + .snapshot(window, cx) + .layout_row(DisplayRow(2), &editor.text_layout_details(window, cx)); + }); +} + +#[gpui::test] +async fn test_rename_without_prepare(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + // These capabilities indicate that the server does not support prepare rename. + let capabilities = lsp::ServerCapabilities { + rename_provider: Some(lsp::OneOf::Left(true)), + ..Default::default() + }; + let mut cx = EditorLspTestContext::new_rust(capabilities, cx).await; + + cx.set_state(indoc! {" + struct Fˇoo {} + "}); + + cx.update_editor(|editor, _window, cx| { + let highlight_range = Point::new(0, 7)..Point::new(0, 10); + let highlight_range = highlight_range.to_anchors(&editor.buffer().read(cx).snapshot(cx)); + editor.highlight_background( + HighlightKey::DocumentHighlightRead, + &[highlight_range], + |_, theme| theme.colors().editor_document_highlight_read_background, + cx, + ); + }); + + cx.update_editor(|e, window, cx| e.rename(&Rename, window, cx)) + .expect("Prepare rename was not started") + .await + .expect("Prepare rename failed"); + + let mut rename_handler = + cx.set_request_handler::(move |url, _, _| async move { + let edit = lsp::TextEdit { + range: lsp::Range { + start: lsp::Position { + line: 0, + character: 7, + }, + end: lsp::Position { + line: 0, + character: 10, + }, + }, + new_text: "FooRenamed".to_string(), + }; + Ok(Some(lsp::WorkspaceEdit::new( + std::collections::HashMap::from_iter(Some((url, vec![edit]))), + ))) + }); + let rename_task = cx + .update_editor(|e, window, cx| e.confirm_rename(&ConfirmRename, window, cx)) + .expect("Confirm rename was not started"); + rename_handler.next().await.unwrap(); + rename_task.await.expect("Confirm rename failed"); + cx.run_until_parked(); + + // Correct range is renamed, as `surrounding_word` is used to find it. + cx.assert_editor_state(indoc! {" + struct FooRenamedˇ {} + "}); +} + +#[gpui::test] +async fn test_tree_sitter_brackets_newline_insertion(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + + let language = Arc::new( + Language::new( + LanguageConfig::default(), + Some(tree_sitter_html::LANGUAGE.into()), + ) + .with_brackets_query( + r#" + ("<" @open "/>" @close) + ("" @close) + ("<" @open ">" @close) + ("\"" @open "\"" @close) + ((element (start_tag) @open (end_tag) @close) (#set! newline.only)) + "#, + ) + .unwrap(), + ); + cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); + + cx.set_state(indoc! {" + ˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.assert_editor_state(indoc! {" + + ˇ + + "}); + + cx.set_state(indoc! {" + ˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.assert_editor_state(indoc! {" + + ˇ + "}); + + cx.set_state(indoc! {" + ˇ + + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.assert_editor_state(indoc! {" + + ˇ + + "}); +} + +#[gpui::test(iterations = 10)] +async fn test_apply_code_lens_actions_with_commands(cx: &mut gpui::TestAppContext) { + init_test(cx, |_| {}); + update_test_editor_settings(cx, &|settings| { + settings.code_lens = Some(settings::CodeLens::Menu); + }); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/dir"), + json!({ + "a.ts": "a", + }), + ) + .await; + + let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(*window, cx); + + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(Arc::new(Language::new( + LanguageConfig { + name: "TypeScript".into(), + matcher: (LanguageMatcher { + path_suffixes: vec!["ts".to_string()], + ..Default::default() + }) + .into(), + ..Default::default() + }, + Some(tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into()), + ))); + let mut fake_language_servers = language_registry.register_fake_lsp( + "TypeScript", + FakeLspAdapter { + capabilities: lsp::ServerCapabilities { + code_lens_provider: Some(lsp::CodeLensOptions { + resolve_provider: Some(true), + }), + execute_command_provider: Some(lsp::ExecuteCommandOptions { + commands: vec!["_the/command".to_string()], + ..lsp::ExecuteCommandOptions::default() + }), + ..lsp::ServerCapabilities::default() + }, + ..FakeLspAdapter::default() + }, + ); + + let editor = workspace + .update_in(cx, |workspace, window, cx| { + workspace.open_abs_path( + PathBuf::from(path!("/dir/a.ts")), + OpenOptions::default(), + window, + cx, + ) + }) + .await + .unwrap() + .downcast::() + .unwrap(); + cx.executor().run_until_parked(); + + let fake_server = fake_language_servers.next().await.unwrap(); + + let buffer = editor.update(cx, |editor, cx| { + editor + .buffer() + .read(cx) + .as_singleton() + .expect("have opened a single file by path") + }); + + let buffer_snapshot = buffer.update(cx, |buffer, _| buffer.snapshot()); + let anchor = buffer_snapshot.anchor_at(0, text::Bias::Left); + drop(buffer_snapshot); + let actions = cx + .update_window(*window, |_, window, cx| { + project.code_actions(&buffer, anchor..anchor, window, cx) + }) + .unwrap(); + + fake_server + .set_request_handler::(|_, _| async move { + Ok(Some(vec![ + lsp::CodeLens { + range: lsp::Range::default(), + command: Some(lsp::Command { + title: "Code lens command".to_owned(), + command: "_the/command".to_owned(), + arguments: None, + }), + data: None, + }, + lsp::CodeLens { + range: lsp::Range { + start: lsp::Position { + line: 1, + character: 1, + }, + end: lsp::Position { + line: 1, + character: 1, + }, + }, + command: Some(lsp::Command { + title: "Command not in range".to_owned(), + command: "_the/command".to_owned(), + arguments: None, + }), + data: None, + }, + ])) + }) + .next() + .await; + + let actions = actions.await.unwrap(); + assert_eq!( + actions.len(), + 1, + "Should have only one valid action for the 0..0 range, got: {actions:#?}" + ); + let action = actions[0].clone(); + let apply = project.update(cx, |project, cx| { + project.apply_code_action(buffer.clone(), action, true, cx) + }); + + // Resolving the code action does not populate its edits. In absence of + // edits, we must execute the given command. + fake_server.set_request_handler::( + |mut lens, _| async move { + let lens_command = lens.command.as_mut().expect("should have a command"); + assert_eq!(lens_command.title, "Code lens command"); + lens_command.arguments = Some(vec![json!("the-argument")]); + Ok(lens) + }, + ); + + // While executing the command, the language server sends the editor + // a `workspaceEdit` request. + fake_server + .set_request_handler::({ + let fake = fake_server.clone(); + move |params, _| { + assert_eq!(params.command, "_the/command"); + let fake = fake.clone(); + async move { + fake.server + .request::( + lsp::ApplyWorkspaceEditParams { + label: None, + edit: lsp::WorkspaceEdit { + changes: Some( + [( + lsp::Uri::from_file_path(path!("/dir/a.ts")).unwrap(), + vec![lsp::TextEdit { + range: lsp::Range::new( + lsp::Position::new(0, 0), + lsp::Position::new(0, 0), + ), + new_text: "X".into(), + }], + )] + .into_iter() + .collect(), + ), + ..lsp::WorkspaceEdit::default() + }, + }, + DEFAULT_LSP_REQUEST_TIMEOUT, + ) + .await + .into_response() + .unwrap(); + Ok(Some(json!(null))) + } + } + }) + .next() + .await; + + // Applying the code lens command returns a project transaction containing the edits + // sent by the language server in its `workspaceEdit` request. + let transaction = apply.await.unwrap(); + assert!(transaction.0.contains_key(&buffer)); + buffer.update(cx, |buffer, cx| { + assert_eq!(buffer.text(), "Xa"); + buffer.undo(cx); + assert_eq!(buffer.text(), "a"); + }); + + let actions_after_edits = cx + .update(|window, cx| project.code_actions(&buffer, anchor..anchor, window, cx)) + .unwrap() + .await; + assert_eq!( + actions, actions_after_edits, + "For the same selection, same code lens actions should be returned" + ); + + let _responses = + fake_server.set_request_handler::(|_, _| async move { + panic!("No more code lens requests are expected"); + }); + editor.update_in(cx, |editor, window, cx| { + editor.select_all(&SelectAll, window, cx); + }); + cx.executor().run_until_parked(); + let new_actions = cx + .update(|window, cx| project.code_actions(&buffer, anchor..anchor, window, cx)) + .unwrap() + .await; + assert_eq!( + actions, new_actions, + "Code lens are queried for the same range and should get the same set back, but without additional LSP queries now" + ); +} + +#[gpui::test] +async fn test_reveal_if_open_uses_user_setting_for_loaded_editor(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + cx.update_global(|store: &mut SettingsStore, cx| { + store + .set_user_settings(r#"{ "reveal_if_open": true }"#, cx) + .unwrap(); + }); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/root"), json!({ "file.txt": "content" })) + .await; + let project = Project::test(fs, [path!("/root").as_ref()], cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = + multi_workspace.read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone()); + let worktree_id = project.read_with(cx, |project, cx| { + project.worktrees(cx).next().unwrap().read(cx).id() + }); + let left_pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone()); + let original_editor = workspace + .update_in(cx, |workspace, window, cx| { + workspace.open_path((worktree_id, rel_path("file.txt")), None, true, window, cx) + }) + .await + .unwrap() + .downcast::() + .unwrap(); + let buffer = original_editor.read_with(cx, |editor, cx| { + editor.buffer().read(cx).as_singleton().unwrap() + }); + + let right_pane = workspace.update_in(cx, |workspace, window, cx| { + let right_pane = workspace.split_pane(left_pane.clone(), SplitDirection::Right, window, cx); + window.focus(&right_pane.focus_handle(cx), cx); + right_pane + }); + cx.run_until_parked(); + workspace.read_with(cx, |workspace, _| { + assert_eq!(workspace.active_pane(), &right_pane); + }); + + let revealed_editor = workspace.update_in(cx, |workspace, window, cx| { + workspace.open_project_item(None, buffer.clone(), true, true, false, false, window, cx) + }); + assert_eq!(revealed_editor, original_editor); + assert_eq!(left_pane.read_with(cx, |pane, _| pane.items_len()), 1); + assert_eq!(right_pane.read_with(cx, |pane, _| pane.items_len()), 0); + workspace.read_with(cx, |workspace, _| { + assert_eq!(workspace.active_pane(), &left_pane); + }); + + let duplicate_editor = workspace.update_in(cx, |workspace, window, cx| { + workspace.open_project_item( + Some(right_pane.clone()), + buffer, + true, + true, + false, + false, + window, + cx, + ) + }); + assert_ne!(duplicate_editor, original_editor); + assert_eq!(right_pane.read_with(cx, |pane, _| pane.items_len()), 1); +} + +#[gpui::test] +async fn test_editor_restore_data_different_in_panes(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let fs = FakeFs::new(cx.executor()); + let main_text = r#"fn main() { +println!("1"); +println!("2"); +println!("3"); +println!("4"); +println!("5"); +}"#; + let lib_text = "mod foo {}"; + fs.insert_tree( + path!("/a"), + json!({ + "lib.rs": lib_text, + "main.rs": main_text, + }), + ) + .await; + + let project = Project::test(fs, [path!("/a").as_ref()], cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); + let worktree_id = workspace.update(cx, |workspace, cx| { + workspace.project().update(cx, |project, cx| { + project.worktrees(cx).next().unwrap().read(cx).id() + }) + }); + + let expected_ranges = vec![ + Point::new(0, 0)..Point::new(0, 0), + Point::new(1, 0)..Point::new(1, 1), + Point::new(2, 0)..Point::new(2, 2), + Point::new(3, 0)..Point::new(3, 3), + ]; + + let pane_1 = workspace.update(cx, |workspace, _| workspace.active_pane().clone()); + let editor_1 = workspace + .update_in(cx, |workspace, window, cx| { + workspace.open_path( + (worktree_id, rel_path("main.rs")), + Some(pane_1.downgrade()), + true, + window, + cx, + ) + }) + .unwrap() + .await + .downcast::() + .unwrap(); + pane_1.update(cx, |pane, cx| { + let open_editor = pane.active_item().unwrap().downcast::().unwrap(); + open_editor.update(cx, |editor, cx| { + assert_eq!( + editor.display_text(cx), + main_text, + "Original main.rs text on initial open", + ); + assert_eq!( + editor + .selections + .all::(&editor.display_snapshot(cx)) + .into_iter() + .map(|s| s.range()) + .collect::>(), + vec![Point::zero()..Point::zero()], + "Default selections on initial open", + ); + }) + }); + editor_1.update_in(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_ranges(expected_ranges.clone()); + }); + }); + + let pane_2 = workspace.update_in(cx, |workspace, window, cx| { + workspace.split_pane(pane_1.clone(), SplitDirection::Right, window, cx) + }); + let editor_2 = workspace + .update_in(cx, |workspace, window, cx| { + workspace.open_path( + (worktree_id, rel_path("main.rs")), + Some(pane_2.downgrade()), + true, + window, + cx, + ) + }) + .unwrap() + .await + .downcast::() + .unwrap(); + pane_2.update(cx, |pane, cx| { + let open_editor = pane.active_item().unwrap().downcast::().unwrap(); + open_editor.update(cx, |editor, cx| { + assert_eq!( + editor.display_text(cx), + main_text, + "Original main.rs text on initial open in another panel", + ); + assert_eq!( + editor + .selections + .all::(&editor.display_snapshot(cx)) + .into_iter() + .map(|s| s.range()) + .collect::>(), + vec![Point::zero()..Point::zero()], + "Default selections on initial open in another panel", + ); + }) + }); + + editor_2.update_in(cx, |editor, window, cx| { + editor.fold_ranges(expected_ranges.clone(), false, window, cx); + }); + + let _other_editor_1 = workspace + .update_in(cx, |workspace, window, cx| { + workspace.open_path( + (worktree_id, rel_path("lib.rs")), + Some(pane_1.downgrade()), + true, + window, + cx, + ) + }) + .unwrap() + .await + .downcast::() + .unwrap(); + pane_1 + .update_in(cx, |pane, window, cx| { + pane.close_other_items(&CloseOtherItems::default(), None, window, cx) + }) + .await + .unwrap(); + drop(editor_1); + pane_1.update(cx, |pane, cx| { + pane.active_item() + .unwrap() + .downcast::() + .unwrap() + .update(cx, |editor, cx| { + assert_eq!( + editor.display_text(cx), + lib_text, + "Other file should be open and active", + ); + }); + assert_eq!(pane.items().count(), 1, "No other editors should be open"); + }); + + let _other_editor_2 = workspace + .update_in(cx, |workspace, window, cx| { + workspace.open_path( + (worktree_id, rel_path("lib.rs")), + Some(pane_2.downgrade()), + true, + window, + cx, + ) + }) + .unwrap() + .await + .downcast::() + .unwrap(); + pane_2 + .update_in(cx, |pane, window, cx| { + pane.close_other_items(&CloseOtherItems::default(), None, window, cx) + }) + .await + .unwrap(); + drop(editor_2); + pane_2.update(cx, |pane, cx| { + let open_editor = pane.active_item().unwrap().downcast::().unwrap(); + open_editor.update(cx, |editor, cx| { + assert_eq!( + editor.display_text(cx), + lib_text, + "Other file should be open and active in another panel too", + ); + }); + assert_eq!( + pane.items().count(), + 1, + "No other editors should be open in another pane", + ); + }); + + let _editor_1_reopened = workspace + .update_in(cx, |workspace, window, cx| { + workspace.open_path( + (worktree_id, rel_path("main.rs")), + Some(pane_1.downgrade()), + true, + window, + cx, + ) + }) + .unwrap() + .await + .downcast::() + .unwrap(); + let _editor_2_reopened = workspace + .update_in(cx, |workspace, window, cx| { + workspace.open_path( + (worktree_id, rel_path("main.rs")), + Some(pane_2.downgrade()), + true, + window, + cx, + ) + }) + .unwrap() + .await + .downcast::() + .unwrap(); + pane_1.update(cx, |pane, cx| { + let open_editor = pane.active_item().unwrap().downcast::().unwrap(); + open_editor.update(cx, |editor, cx| { + assert_eq!( + editor.display_text(cx), + main_text, + "Previous editor in the 1st panel had no extra text manipulations and should get none on reopen", + ); + assert_eq!( + editor + .selections + .all::(&editor.display_snapshot(cx)) + .into_iter() + .map(|s| s.range()) + .collect::>(), + expected_ranges, + "Previous editor in the 1st panel had selections and should get them restored on reopen", + ); + }) + }); + pane_2.update(cx, |pane, cx| { + let open_editor = pane.active_item().unwrap().downcast::().unwrap(); + open_editor.update(cx, |editor, cx| { + assert_eq!( + editor.display_text(cx), + r#"fn main() { +⋯rintln!("1"); +⋯intln!("2"); +⋯ntln!("3"); +println!("4"); +println!("5"); +}"#, + "Previous editor in the 2nd pane had folds and should restore those on reopen in the same pane", + ); + assert_eq!( + editor + .selections + .all::(&editor.display_snapshot(cx)) + .into_iter() + .map(|s| s.range()) + .collect::>(), + vec![Point::zero()..Point::zero()], + "Previous editor in the 2nd pane had no selections changed hence should restore none", + ); + }) + }); +} + +#[gpui::test] +async fn test_editor_does_not_restore_data_when_turned_off(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let fs = FakeFs::new(cx.executor()); + let main_text = r#"fn main() { +println!("1"); +println!("2"); +println!("3"); +println!("4"); +println!("5"); +}"#; + let lib_text = "mod foo {}"; + fs.insert_tree( + path!("/a"), + json!({ + "lib.rs": lib_text, + "main.rs": main_text, + }), + ) + .await; + + let project = Project::test(fs, [path!("/a").as_ref()], cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); + let worktree_id = workspace.update(cx, |workspace, cx| { + workspace.project().update(cx, |project, cx| { + project.worktrees(cx).next().unwrap().read(cx).id() + }) + }); + + let pane = workspace.update(cx, |workspace, _| workspace.active_pane().clone()); + let editor = workspace + .update_in(cx, |workspace, window, cx| { + workspace.open_path( + (worktree_id, rel_path("main.rs")), + Some(pane.downgrade()), + true, + window, + cx, + ) + }) + .unwrap() + .await + .downcast::() + .unwrap(); + pane.update(cx, |pane, cx| { + let open_editor = pane.active_item().unwrap().downcast::().unwrap(); + open_editor.update(cx, |editor, cx| { + assert_eq!( + editor.display_text(cx), + main_text, + "Original main.rs text on initial open", + ); + }) + }); + editor.update_in(cx, |editor, window, cx| { + editor.fold_ranges(vec![Point::new(0, 0)..Point::new(0, 0)], false, window, cx); + }); + + cx.update_global(|store: &mut SettingsStore, cx| { + store.update_user_settings(cx, |s| { + s.workspace.restore_on_file_reopen = Some(false); + }); + }); + editor.update_in(cx, |editor, window, cx| { + editor.fold_ranges( + vec![ + Point::new(1, 0)..Point::new(1, 1), + Point::new(2, 0)..Point::new(2, 2), + Point::new(3, 0)..Point::new(3, 3), + ], + false, + window, + cx, + ); + }); + pane.update_in(cx, |pane, window, cx| { + pane.close_all_items(&CloseAllItems::default(), window, cx) + }) + .await + .unwrap(); + pane.update(cx, |pane, _| { + assert!(pane.active_item().is_none()); + }); + cx.update_global(|store: &mut SettingsStore, cx| { + store.update_user_settings(cx, |s| { + s.workspace.restore_on_file_reopen = Some(true); + }); + }); + + let _editor_reopened = workspace + .update_in(cx, |workspace, window, cx| { + workspace.open_path( + (worktree_id, rel_path("main.rs")), + Some(pane.downgrade()), + true, + window, + cx, + ) + }) + .unwrap() + .await + .downcast::() + .unwrap(); + pane.update(cx, |pane, cx| { + let open_editor = pane.active_item().unwrap().downcast::().unwrap(); + open_editor.update(cx, |editor, cx| { + assert_eq!( + editor.display_text(cx), + main_text, + "No folds: even after enabling the restoration, previous editor's data should not be saved to be used for the restoration" + ); + }) + }); +} + +struct EmptyModalView { + focus_handle: gpui::FocusHandle, +} + +impl EventEmitter for EmptyModalView {} + +impl Render for EmptyModalView { + fn render(&mut self, _: &mut Window, _: &mut Context<'_, Self>) -> impl IntoElement { + div() + } +} + +impl Focusable for EmptyModalView { + fn focus_handle(&self, _cx: &App) -> gpui::FocusHandle { + self.focus_handle.clone() + } +} + +impl workspace::ModalView for EmptyModalView {} + +impl EmptyModalView { + fn new(cx: &App) -> Self { + Self { + focus_handle: cx.focus_handle(), + } + } +} + +#[gpui::test] +async fn test_hide_mouse_context_menu_on_modal_opened(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [], cx).await; + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let buffer = cx.update(|cx| MultiBuffer::build_simple("hello world!", cx)); + let cx = &mut VisualTestContext::from_window(*window, cx); + let editor = cx.new_window_entity(|window, cx| { + Editor::new( + EditorMode::full(), + buffer, + Some(project.clone()), + window, + cx, + ) + }); + workspace.update_in(cx, |workspace, window, cx| { + workspace.add_item_to_active_pane(Box::new(editor.clone()), None, true, window, cx); + }); + + editor.update_in(cx, |editor, window, cx| { + editor.open_context_menu(&OpenContextMenu, window, cx); + assert!(editor.mouse_context_menu.is_some()); + }); + workspace.update_in(cx, |workspace, window, cx| { + workspace.toggle_modal(window, cx, |_, cx| EmptyModalView::new(cx)); + }); + + cx.read(|cx| { + assert!(editor.read(cx).mouse_context_menu.is_none()); + }); +} + +#[gpui::test] +async fn test_hide_pending_blame_popover_when_modal_opens(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [], cx).await; + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone()) + .unwrap(); + let multi_buffer = cx.update(|cx| MultiBuffer::build_simple("Buffer Contents!", cx)); + let buffer_id = multi_buffer.read_with(cx, |multi_buffer, cx| { + multi_buffer + .all_buffers_iter() + .next() + .expect("Should have at least one buffer") + .read(cx) + .remote_id() + }); + let cx = &mut VisualTestContext::from_window(*window, cx); + let editor = cx.new_window_entity(|window, cx| { + Editor::new( + EditorMode::full(), + multi_buffer, + Some(project.clone()), + window, + cx, + ) + }); + + workspace.update_in(cx, |workspace, window, cx| { + workspace.add_item_to_active_pane(Box::new(editor.clone()), None, true, window, cx); + }); + + editor.update_in(cx, |editor, _, cx| { + editor.blame = Some( + cx.new(|cx| GitBlame::new(editor.buffer.clone(), project.clone(), false, true, cx)), + ); + editor.show_blame_popover( + buffer_id, + &::git::blame::BlameEntry { + sha: "1b1b1b".parse().unwrap(), + range: 0..1, + original_line_number: 0, + author: None, + author_mail: None, + author_time: None, + author_tz: None, + committer_name: None, + committer_email: None, + committer_time: None, + committer_tz: None, + summary: None, + previous: None, + filename: String::new(), + boundary: false, + }, + gpui::point(gpui::px(0.), gpui::px(0.)), + false, + cx, + ); + + assert!(editor.inline_blame_popover_show_task.is_some()); + assert!(editor.inline_blame_popover.is_none()); + }); + + workspace.update_in(cx, |workspace, window, cx| { + workspace.toggle_modal(window, cx, |_, cx| EmptyModalView::new(cx)); + }); + + // Toggling a modal while the blame popover task is still pending should + // clear both the task and any rendered popover. + editor.update_in(cx, |editor, _, _| { + assert!(editor.inline_blame_popover.is_none()); + assert!(editor.inline_blame_popover_show_task.is_none()); + }); +} + +fn set_linked_edit_ranges( + opening: (Point, Point), + closing: (Point, Point), + editor: &mut Editor, + cx: &mut Context, +) { + let Some((buffer, _)) = editor + .buffer + .read(cx) + .text_anchor_for_position(editor.selections.newest_anchor().start, cx) + else { + panic!("Failed to get buffer for selection position"); + }; + let buffer = buffer.read(cx); + let buffer_id = buffer.remote_id(); + let opening_range = buffer.anchor_before(opening.0)..buffer.anchor_after(opening.1); + let closing_range = buffer.anchor_before(closing.0)..buffer.anchor_after(closing.1); + let mut linked_ranges = HashMap::default(); + linked_ranges.insert(buffer_id, vec![(opening_range, vec![closing_range])]); + editor.linked_edit_ranges = LinkedEditingRanges(linked_ranges); +} + +#[gpui::test] +async fn test_html_linked_edits_on_completion(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let fs = FakeFs::new(cx.executor()); + fs.insert_file(path!("/file.html"), Default::default()) + .await; + + let project = Project::test(fs, [path!("/").as_ref()], cx).await; + + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + let html_language = Arc::new(Language::new( + LanguageConfig { + name: "HTML".into(), + matcher: (LanguageMatcher { + path_suffixes: vec!["html".to_string()], + ..LanguageMatcher::default() + }) + .into(), + brackets: BracketPairConfig { + pairs: vec![BracketPair { + start: "<".into(), + end: ">".into(), + close: true, + ..Default::default() + }], + ..Default::default() + }, + ..Default::default() + }, + Some(tree_sitter_html::LANGUAGE.into()), + )); + language_registry.add(html_language); + let mut fake_servers = language_registry.register_fake_lsp( + "HTML", + FakeLspAdapter { + capabilities: lsp::ServerCapabilities { + completion_provider: Some(lsp::CompletionOptions { + resolve_provider: Some(true), + ..Default::default() + }), + ..Default::default() + }, + ..Default::default() + }, + ); + + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(*window, cx); + + let worktree_id = workspace.update_in(cx, |workspace, _window, cx| { + workspace.project().update(cx, |project, cx| { + project.worktrees(cx).next().unwrap().read(cx).id() + }) + }); + + project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/file.html"), cx) + }) + .await + .unwrap(); + let editor = workspace + .update_in(cx, |workspace, window, cx| { + workspace.open_path((worktree_id, rel_path("file.html")), None, true, window, cx) + }) + .await + .unwrap() + .downcast::() + .unwrap(); + + let fake_server = fake_servers.next().await.unwrap(); + cx.run_until_parked(); + editor.update_in(cx, |editor, window, cx| { + editor.set_text("", window, cx); + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| { + selections.select_ranges([Point::new(0, 3)..Point::new(0, 3)]); + }); + set_linked_edit_ranges( + (Point::new(0, 1), Point::new(0, 3)), + (Point::new(0, 6), Point::new(0, 8)), + editor, + cx, + ); + }); + let mut completion_handle = + fake_server.set_request_handler::(move |_, _| async move { + Ok(Some(lsp::CompletionResponse::Array(vec![ + lsp::CompletionItem { + label: "head".to_string(), + text_edit: Some(lsp::CompletionTextEdit::InsertAndReplace( + lsp::InsertReplaceEdit { + new_text: "head".to_string(), + insert: lsp::Range::new( + lsp::Position::new(0, 1), + lsp::Position::new(0, 3), + ), + replace: lsp::Range::new( + lsp::Position::new(0, 1), + lsp::Position::new(0, 3), + ), + }, + )), + ..Default::default() + }, + ]))) + }); + editor.update_in(cx, |editor, window, cx| { + editor.show_completions(&ShowCompletions, window, cx); + }); + cx.run_until_parked(); + completion_handle.next().await.unwrap(); + editor.update(cx, |editor, _| { + assert!( + editor.context_menu_visible(), + "Completion menu should be visible" + ); + }); + editor.update_in(cx, |editor, window, cx| { + editor.confirm_completion(&ConfirmCompletion::default(), window, cx) + }); + cx.executor().run_until_parked(); + editor.update(cx, |editor, cx| { + assert_eq!(editor.text(cx), ""); + }); +} + +#[gpui::test] +async fn test_linked_edits_on_typing_punctuation(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + let language = Arc::new(Language::new( + LanguageConfig { + name: "TSX".into(), + matcher: (LanguageMatcher { + path_suffixes: vec!["tsx".to_string()], + ..LanguageMatcher::default() + }) + .into(), + brackets: BracketPairConfig { + pairs: vec![BracketPair { + start: "<".into(), + end: ">".into(), + close: true, + ..Default::default() + }], + ..Default::default() + }, + linked_edit_characters: HashSet::from_iter(['.']), + ..Default::default() + }, + Some(tree_sitter_typescript::LANGUAGE_TSX.into()), + )); + cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); + + // Test typing > does not extend linked pair + cx.set_state(""); + cx.update_editor(|editor, _, cx| { + set_linked_edit_ranges( + (Point::new(0, 1), Point::new(0, 4)), + (Point::new(0, 11), Point::new(0, 14)), + editor, + cx, + ); + }); + cx.update_editor(|editor, window, cx| { + editor.handle_input(">", window, cx); + }); + cx.assert_editor_state("
ˇ
"); + + // Test typing . do extend linked pair + cx.set_state(""); + cx.update_editor(|editor, _, cx| { + set_linked_edit_ranges( + (Point::new(0, 1), Point::new(0, 9)), + (Point::new(0, 12), Point::new(0, 20)), + editor, + cx, + ); + }); + cx.update_editor(|editor, window, cx| { + editor.handle_input(".", window, cx); + }); + cx.assert_editor_state(""); + cx.update_editor(|editor, _, cx| { + set_linked_edit_ranges( + (Point::new(0, 1), Point::new(0, 10)), + (Point::new(0, 13), Point::new(0, 21)), + editor, + cx, + ); + }); + cx.update_editor(|editor, window, cx| { + editor.handle_input("V", window, cx); + }); + cx.assert_editor_state(""); +} + +#[gpui::test] +async fn test_linked_edits_on_typing_dash_in_custom_element_name(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + let language = Arc::new(Language::new( + LanguageConfig { + name: "TSX".into(), + matcher: (LanguageMatcher { + path_suffixes: vec!["tsx".to_string()], + ..LanguageMatcher::default() + }) + .into(), + brackets: BracketPairConfig { + pairs: vec![BracketPair { + start: "<".into(), + end: ">".into(), + close: true, + ..Default::default() + }], + ..Default::default() + }, + // Mirrors crates/grammars/src/{tsx,javascript}/config.toml: a dash is a valid + // JSXIdentifier character, so custom element names like `` must keep + // the closing tag linked past the dash. Regression test for #43060 / #58553. + linked_edit_characters: HashSet::from_iter(['.', '-']), + ..Default::default() + }, + Some(tree_sitter_typescript::LANGUAGE_TSX.into()), + )); + cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); + + // Typing the dash while renaming a tag must extend the linked pair rather than + // dropping it (the bug produced ``). + cx.set_state(""); + cx.update_editor(|editor, _, cx| { + set_linked_edit_ranges( + (Point::new(0, 1), Point::new(0, 7)), + (Point::new(0, 10), Point::new(0, 16)), + editor, + cx, + ); + }); + cx.update_editor(|editor, window, cx| { + editor.handle_input("-", window, cx); + }); + cx.assert_editor_state(""); + + // Characters typed after the dash continue to mirror into the closing tag. + cx.update_editor(|editor, _, cx| { + set_linked_edit_ranges( + (Point::new(0, 1), Point::new(0, 8)), + (Point::new(0, 11), Point::new(0, 18)), + editor, + cx, + ); + }); + cx.update_editor(|editor, window, cx| { + editor.handle_input("el", window, cx); + }); + cx.assert_editor_state(""); +} + +#[gpui::test] +async fn test_linked_edits_on_typing_dot_without_language_override(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + let language = Arc::new(Language::new( + LanguageConfig { + name: "HTML".into(), + matcher: (LanguageMatcher { + path_suffixes: vec!["html".to_string()], + ..LanguageMatcher::default() + }) + .into(), + brackets: BracketPairConfig { + pairs: vec![BracketPair { + start: "<".into(), + end: ">".into(), + close: true, + ..Default::default() + }], + ..Default::default() + }, + ..Default::default() + }, + Some(tree_sitter_html::LANGUAGE.into()), + )); + cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); + + cx.set_state(""); + cx.update_editor(|editor, _, cx| { + set_linked_edit_ranges( + (Point::new(0, 1), Point::new(0, 6)), + (Point::new(0, 9), Point::new(0, 14)), + editor, + cx, + ); + }); + cx.update_editor(|editor, window, cx| { + editor.handle_input(".", window, cx); + }); + cx.assert_editor_state(""); +} + +#[gpui::test] +async fn test_invisible_worktree_servers(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/root"), + json!({ + "a": { + "main.rs": "fn main() {}", + }, + "foo": { + "bar": { + "external_file.rs": "pub mod external {}", + } + } + }), + ) + .await; + + let project = Project::test(fs, [path!("/root/a").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(rust_lang()); + let _fake_servers = language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + ..FakeLspAdapter::default() + }, + ); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); + let worktree_id = workspace.update(cx, |workspace, cx| { + workspace.project().update(cx, |project, cx| { + project.worktrees(cx).next().unwrap().read(cx).id() + }) + }); + + let assert_language_servers_count = + |expected: usize, context: &str, cx: &mut VisualTestContext| { + project.update(cx, |project, cx| { + let current = project + .lsp_store() + .read(cx) + .as_local() + .unwrap() + .language_servers + .len(); + assert_eq!(expected, current, "{context}"); + }); + }; + + assert_language_servers_count( + 0, + "No servers should be running before any file is open", + cx, + ); + let pane = workspace.update(cx, |workspace, _| workspace.active_pane().clone()); + let main_editor = workspace + .update_in(cx, |workspace, window, cx| { + workspace.open_path( + (worktree_id, rel_path("main.rs")), + Some(pane.downgrade()), + true, + window, + cx, + ) + }) + .unwrap() + .await + .downcast::() + .unwrap(); + pane.update(cx, |pane, cx| { + let open_editor = pane.active_item().unwrap().downcast::().unwrap(); + open_editor.update(cx, |editor, cx| { + assert_eq!( + editor.display_text(cx), + "fn main() {}", + "Original main.rs text on initial open", + ); + }); + assert_eq!(open_editor, main_editor); + }); + assert_language_servers_count(1, "First *.rs file starts a language server", cx); + + let external_editor = workspace + .update_in(cx, |workspace, window, cx| { + workspace.open_abs_path( + PathBuf::from("/root/foo/bar/external_file.rs"), + OpenOptions::default(), + window, + cx, + ) + }) + .await + .expect("opening external file") + .downcast::() + .expect("downcasted external file's open element to editor"); + pane.update(cx, |pane, cx| { + let open_editor = pane.active_item().unwrap().downcast::().unwrap(); + open_editor.update(cx, |editor, cx| { + assert_eq!( + editor.display_text(cx), + "pub mod external {}", + "External file is open now", + ); + }); + assert_eq!(open_editor, external_editor); + }); + assert_language_servers_count( + 1, + "Second, external, *.rs file should join the existing server", + cx, + ); + + pane.update_in(cx, |pane, window, cx| { + pane.close_active_item(&CloseActiveItem::default(), window, cx) + }) + .await + .unwrap(); + pane.update_in(cx, |pane, window, cx| { + pane.navigate_backward(&Default::default(), window, cx); + }); + cx.run_until_parked(); + pane.update(cx, |pane, cx| { + let open_editor = pane.active_item().unwrap().downcast::().unwrap(); + open_editor.update(cx, |editor, cx| { + assert_eq!( + editor.display_text(cx), + "pub mod external {}", + "External file is open now", + ); + }); + }); + assert_language_servers_count( + 1, + "After closing and reopening (with navigate back) of an external file, no extra language servers should appear", + cx, + ); + + cx.update(|_, cx| { + workspace::reload(cx); + }); + assert_language_servers_count( + 1, + "After reloading the worktree with local and external files opened, only one project should be started", + cx, + ); +} + +#[gpui::test] +async fn test_tab_in_leading_whitespace_auto_indents_for_python(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + let language = languages::language("python", tree_sitter_python::LANGUAGE.into()); + cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); + + // test cursor move to start of each line on tab + // for `if`, `elif`, `else`, `while`, `with` and `for` + cx.set_state(indoc! {" + def main(): + ˇ for item in items: + ˇ while item.active: + ˇ if item.value > 10: + ˇ continue + ˇ elif item.value < 0: + ˇ break + ˇ else: + ˇ with item.context() as ctx: + ˇ yield count + ˇ else: + ˇ log('while else') + ˇ else: + ˇ log('for else') + "}); + cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + def main(): + ˇfor item in items: + ˇwhile item.active: + ˇif item.value > 10: + ˇcontinue + ˇelif item.value < 0: + ˇbreak + ˇelse: + ˇwith item.context() as ctx: + ˇyield count + ˇelse: + ˇlog('while else') + ˇelse: + ˇlog('for else') + "}); + // test relative indent is preserved when tab + // for `if`, `elif`, `else`, `while`, `with` and `for` + cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + def main(): + ˇfor item in items: + ˇwhile item.active: + ˇif item.value > 10: + ˇcontinue + ˇelif item.value < 0: + ˇbreak + ˇelse: + ˇwith item.context() as ctx: + ˇyield count + ˇelse: + ˇlog('while else') + ˇelse: + ˇlog('for else') + "}); + + // test cursor move to start of each line on tab + // for `try`, `except`, `else`, `finally`, `match` and `def` + cx.set_state(indoc! {" + def main(): + ˇ try: + ˇ fetch() + ˇ except ValueError: + ˇ handle_error() + ˇ else: + ˇ match value: + ˇ case _: + ˇ finally: + ˇ def status(): + ˇ return 0 + "}); + cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + def main(): + ˇtry: + ˇfetch() + ˇexcept ValueError: + ˇhandle_error() + ˇelse: + ˇmatch value: + ˇcase _: + ˇfinally: + ˇdef status(): + ˇreturn 0 + "}); + // test relative indent is preserved when tab + // for `try`, `except`, `else`, `finally`, `match` and `def` + cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + def main(): + ˇtry: + ˇfetch() + ˇexcept ValueError: + ˇhandle_error() + ˇelse: + ˇmatch value: + ˇcase _: + ˇfinally: + ˇdef status(): + ˇreturn 0 + "}); +} + +#[gpui::test] +async fn test_outdent_after_input_for_python(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + let language = languages::language("python", tree_sitter_python::LANGUAGE.into()); + cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); + + // test `else` auto outdents when typed inside `if` block + cx.set_state(indoc! {" + def main(): + if i == 2: + return + ˇ + "}); + cx.update_editor(|editor, window, cx| { + editor.handle_input("else:", window, cx); + }); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + def main(): + if i == 2: + return + else:ˇ + "}); + + // Completing `else:` at multiple cursors must still trigger syntax outdents. + cx.set_state(indoc! {" + def f(): + if True: + pass + elseˇ + pass + def g(): + if True: + pass + elseˇ + pass + "}); + cx.update_editor(|editor, window, cx| editor.handle_input(":", window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + def f(): + if True: + pass + else:ˇ + pass + def g(): + if True: + pass + else:ˇ + pass + "}); + + // test `except` auto outdents when typed inside `try` block + cx.set_state(indoc! {" + def main(): + try: + i = 2 + ˇ + "}); + cx.update_editor(|editor, window, cx| { + editor.handle_input("except:", window, cx); + }); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + def main(): + try: + i = 2 + except:ˇ + "}); + + // test `else` auto outdents when typed inside `except` block + cx.set_state(indoc! {" + def main(): + try: + i = 2 + except: + j = 2 + ˇ + "}); + cx.update_editor(|editor, window, cx| { + editor.handle_input("else:", window, cx); + }); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + def main(): + try: + i = 2 + except: + j = 2 + else:ˇ + "}); + + // test `finally` auto outdents when typed inside `else` block + cx.set_state(indoc! {" + def main(): + try: + i = 2 + except: + j = 2 + else: + k = 2 + ˇ + "}); + cx.update_editor(|editor, window, cx| { + editor.handle_input("finally:", window, cx); + }); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + def main(): + try: + i = 2 + except: + j = 2 + else: + k = 2 + finally:ˇ + "}); + + // test `else` does not outdents when typed inside `except` block right after for block + cx.set_state(indoc! {" + def main(): + try: + i = 2 + except: + for i in range(n): + pass + ˇ + "}); + cx.update_editor(|editor, window, cx| { + editor.handle_input("else:", window, cx); + }); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + def main(): + try: + i = 2 + except: + for i in range(n): + pass + else:ˇ + "}); + + // test `finally` auto outdents when typed inside `else` block right after for block + cx.set_state(indoc! {" + def main(): + try: + i = 2 + except: + j = 2 + else: + for i in range(n): + pass + ˇ + "}); + cx.update_editor(|editor, window, cx| { + editor.handle_input("finally:", window, cx); + }); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + def main(): + try: + i = 2 + except: + j = 2 + else: + for i in range(n): + pass + finally:ˇ + "}); + + // test `except` outdents to inner "try" block + cx.set_state(indoc! {" + def main(): + try: + i = 2 + if i == 2: + try: + i = 3 + ˇ + "}); + cx.update_editor(|editor, window, cx| { + editor.handle_input("except:", window, cx); + }); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + def main(): + try: + i = 2 + if i == 2: + try: + i = 3 + except:ˇ + "}); + + // test `except` outdents to outer "try" block + cx.set_state(indoc! {" + def main(): + try: + i = 2 + if i == 2: + try: + i = 3 + ˇ + "}); + cx.update_editor(|editor, window, cx| { + editor.handle_input("except:", window, cx); + }); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + def main(): + try: + i = 2 + if i == 2: + try: + i = 3 + except:ˇ + "}); + + // test `else` stays at correct indent when typed after `for` block + cx.set_state(indoc! {" + def main(): + for i in range(10): + if i == 3: + break + ˇ + "}); + cx.update_editor(|editor, window, cx| { + editor.handle_input("else:", window, cx); + }); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + def main(): + for i in range(10): + if i == 3: + break + else:ˇ + "}); + + // test does not outdent on typing after line with square brackets + cx.set_state(indoc! {" + def f() -> list[str]: + ˇ + "}); + cx.update_editor(|editor, window, cx| { + editor.handle_input("a", window, cx); + }); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + def f() -> list[str]: + aˇ + "}); + + // test does not outdent on typing : after case keyword + cx.set_state(indoc! {" + match 1: + caseˇ + "}); + cx.update_editor(|editor, window, cx| { + editor.handle_input(":", window, cx); + }); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + match 1: + case:ˇ + "}); +} + +#[gpui::test] +async fn test_indent_on_newline_for_python(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + update_test_language_settings(cx, &|settings| { + settings.defaults.extend_comment_on_newline = Some(false); + }); + let mut cx = EditorTestContext::new(cx).await; + let language = languages::language("python", tree_sitter_python::LANGUAGE.into()); + cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); + + // test correct indent after newline on comment + cx.set_state(indoc! {" + # COMMENT:ˇ + "}); + cx.update_editor(|editor, window, cx| { + editor.newline(&Newline, window, cx); + }); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + # COMMENT: + ˇ + "}); + + // test correct indent after newline in brackets + cx.set_state(indoc! {" + {ˇ} + "}); + cx.update_editor(|editor, window, cx| { + editor.newline(&Newline, window, cx); + }); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + { + ˇ + } + "}); + + cx.set_state(indoc! {" + (ˇ) + "}); + cx.update_editor(|editor, window, cx| { + editor.newline(&Newline, window, cx); + }); + cx.run_until_parked(); + cx.assert_editor_state(indoc! {" + ( + ˇ + ) + "}); + + // do not indent after empty lists or dictionaries + cx.set_state(indoc! {" + a = []ˇ + "}); + cx.update_editor(|editor, window, cx| { + editor.newline(&Newline, window, cx); + }); + cx.run_until_parked(); + cx.assert_editor_state(indoc! {" + a = [] + ˇ + "}); +} + +#[gpui::test] +async fn test_python_indent_in_markdown(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let language_registry = Arc::new(language::LanguageRegistry::test(cx.executor())); + let python_lang = languages::language("python", tree_sitter_python::LANGUAGE.into()); + language_registry.add(markdown_lang()); + language_registry.add(python_lang); + + let mut cx = EditorTestContext::new(cx).await; + cx.update_buffer(|buffer, cx| { + buffer.set_language_registry(language_registry); + buffer.set_language(Some(markdown_lang()), cx); + }); + + // Test that `else:` correctly outdents to match `if:` inside the Python code block + cx.set_state(indoc! {" + # Heading + + ```python + def main(): + if condition: + pass + ˇ + ``` + "}); + cx.update_editor(|editor, window, cx| { + editor.handle_input("else:", window, cx); + }); + cx.run_until_parked(); + cx.assert_editor_state(indoc! {" + # Heading + + ```python + def main(): + if condition: + pass + else:ˇ + ``` + "}); +} + +#[gpui::test] +async fn test_tab_in_leading_whitespace_auto_indents_for_bash(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + let language = languages::language("bash", tree_sitter_bash::LANGUAGE.into()); + cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); + + // test cursor move to start of each line on tab + // for `if`, `elif`, `else`, `while`, `for`, `case` and `function` + cx.set_state(indoc! {" + function main() { + ˇ for item in $items; do + ˇ while [ -n \"$item\" ]; do + ˇ if [ \"$value\" -gt 10 ]; then + ˇ continue + ˇ elif [ \"$value\" -lt 0 ]; then + ˇ break + ˇ else + ˇ echo \"$item\" + ˇ fi + ˇ done + ˇ done + ˇ} + "}); + cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + function main() { + ˇfor item in $items; do + ˇwhile [ -n \"$item\" ]; do + ˇif [ \"$value\" -gt 10 ]; then + ˇcontinue + ˇelif [ \"$value\" -lt 0 ]; then + ˇbreak + ˇelse + ˇecho \"$item\" + ˇfi + ˇdone + ˇdone + ˇ} + "}); + // test relative indent is preserved when tab + cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + function main() { + ˇfor item in $items; do + ˇwhile [ -n \"$item\" ]; do + ˇif [ \"$value\" -gt 10 ]; then + ˇcontinue + ˇelif [ \"$value\" -lt 0 ]; then + ˇbreak + ˇelse + ˇecho \"$item\" + ˇfi + ˇdone + ˇdone + ˇ} + "}); + + // test cursor move to start of each line on tab + // for `case` statement with patterns + cx.set_state(indoc! {" + function handle() { + ˇ case \"$1\" in + ˇ start) + ˇ echo \"a\" + ˇ ;; + ˇ stop) + ˇ echo \"b\" + ˇ ;; + ˇ *) + ˇ echo \"c\" + ˇ ;; + ˇ esac + ˇ} + "}); + cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + function handle() { + ˇcase \"$1\" in + ˇstart) + ˇecho \"a\" + ˇ;; + ˇstop) + ˇecho \"b\" + ˇ;; + ˇ*) + ˇecho \"c\" + ˇ;; + ˇesac + ˇ} + "}); +} + +#[gpui::test] +async fn test_indent_after_input_for_bash(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + let language = languages::language("bash", tree_sitter_bash::LANGUAGE.into()); + cx.update_buffer(|buffer, cx| { + buffer.set_language(Some(language), cx); + buffer.set_sync_parse_timeout(None); + }); + + // test indents on comment insert + cx.set_state(indoc! {" + function main() { + ˇ for item in $items; do + ˇ while [ -n \"$item\" ]; do + ˇ if [ \"$value\" -gt 10 ]; then + ˇ continue + ˇ elif [ \"$value\" -lt 0 ]; then + ˇ break + ˇ else + ˇ echo \"$item\" + ˇ fi + ˇ done + ˇ done + ˇ} + "}); + cx.update_editor(|e, window, cx| e.handle_input("#", window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + function main() { + #ˇ for item in $items; do + #ˇ while [ -n \"$item\" ]; do + #ˇ if [ \"$value\" -gt 10 ]; then + #ˇ continue + #ˇ elif [ \"$value\" -lt 0 ]; then + #ˇ break + #ˇ else + #ˇ echo \"$item\" + #ˇ fi + #ˇ done + #ˇ done + #ˇ} + "}); +} + +#[gpui::test] +async fn test_outdent_after_input_for_bash(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + let language = languages::language("bash", tree_sitter_bash::LANGUAGE.into()); + cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); + + // test `else` auto outdents when typed inside `if` block + cx.set_state(indoc! {" + if [ \"$1\" = \"test\" ]; then + echo \"foo bar\" + ˇ + "}); + cx.update_editor(|editor, window, cx| { + editor.handle_input("else", window, cx); + }); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + if [ \"$1\" = \"test\" ]; then + echo \"foo bar\" + elseˇ + "}); + + // test `elif` auto outdents when typed inside `if` block + cx.set_state(indoc! {" + if [ \"$1\" = \"test\" ]; then + echo \"foo bar\" + ˇ + "}); + cx.update_editor(|editor, window, cx| { + editor.handle_input("elif", window, cx); + }); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + if [ \"$1\" = \"test\" ]; then + echo \"foo bar\" + elifˇ + "}); + + // test `fi` auto outdents when typed inside `else` block + cx.set_state(indoc! {" + if [ \"$1\" = \"test\" ]; then + echo \"foo bar\" + else + echo \"bar baz\" + ˇ + "}); + cx.update_editor(|editor, window, cx| { + editor.handle_input("fi", window, cx); + }); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + if [ \"$1\" = \"test\" ]; then + echo \"foo bar\" + else + echo \"bar baz\" + fiˇ + "}); + + // test `done` auto outdents when typed inside `while` block + cx.set_state(indoc! {" + while read line; do + echo \"$line\" + ˇ + "}); + cx.update_editor(|editor, window, cx| { + editor.handle_input("done", window, cx); + }); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + while read line; do + echo \"$line\" + doneˇ + "}); + + // test `done` auto outdents when typed inside `for` block + cx.set_state(indoc! {" + for file in *.txt; do + cat \"$file\" + ˇ + "}); + cx.update_editor(|editor, window, cx| { + editor.handle_input("done", window, cx); + }); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + for file in *.txt; do + cat \"$file\" + doneˇ + "}); + + // test `esac` auto outdents when typed inside `case` block + cx.set_state(indoc! {" + case \"$1\" in + start) + echo \"foo bar\" + ;; + stop) + echo \"bar baz\" + ;; + ˇ + "}); + cx.update_editor(|editor, window, cx| { + editor.handle_input("esac", window, cx); + }); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + case \"$1\" in + start) + echo \"foo bar\" + ;; + stop) + echo \"bar baz\" + ;; + esacˇ + "}); + + // test `*)` auto outdents when typed inside `case` block + cx.set_state(indoc! {" + case \"$1\" in + start) + echo \"foo bar\" + ;; + ˇ + "}); + cx.update_editor(|editor, window, cx| { + editor.handle_input("*)", window, cx); + }); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + case \"$1\" in + start) + echo \"foo bar\" + ;; + *)ˇ + "}); + + // test `fi` outdents to correct level with nested if blocks + cx.set_state(indoc! {" + if [ \"$1\" = \"test\" ]; then + echo \"outer if\" + if [ \"$2\" = \"debug\" ]; then + echo \"inner if\" + ˇ + "}); + cx.update_editor(|editor, window, cx| { + editor.handle_input("fi", window, cx); + }); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + if [ \"$1\" = \"test\" ]; then + echo \"outer if\" + if [ \"$2\" = \"debug\" ]; then + echo \"inner if\" + fiˇ + "}); +} + +#[gpui::test] +async fn test_indent_on_newline_for_bash(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + update_test_language_settings(cx, &|settings| { + settings.defaults.extend_comment_on_newline = Some(false); + }); + let mut cx = EditorTestContext::new(cx).await; + let language = languages::language("bash", tree_sitter_bash::LANGUAGE.into()); + cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); + + // test correct indent after newline on comment + cx.set_state(indoc! {" + # COMMENT:ˇ + "}); + cx.update_editor(|editor, window, cx| { + editor.newline(&Newline, window, cx); + }); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + # COMMENT: + ˇ + "}); + + // test correct indent after newline after `then` + cx.set_state(indoc! {" + + if [ \"$1\" = \"test\" ]; thenˇ + "}); + cx.update_editor(|editor, window, cx| { + editor.newline(&Newline, window, cx); + }); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + + if [ \"$1\" = \"test\" ]; then + ˇ + "}); + + // test correct indent after newline after `else` + cx.set_state(indoc! {" + if [ \"$1\" = \"test\" ]; then + elseˇ + "}); + cx.update_editor(|editor, window, cx| { + editor.newline(&Newline, window, cx); + }); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + if [ \"$1\" = \"test\" ]; then + else + ˇ + "}); + + // test correct indent after newline after `elif` + cx.set_state(indoc! {" + if [ \"$1\" = \"test\" ]; then + elifˇ + "}); + cx.update_editor(|editor, window, cx| { + editor.newline(&Newline, window, cx); + }); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + if [ \"$1\" = \"test\" ]; then + elif + ˇ + "}); + + // test correct indent after newline after `do` + cx.set_state(indoc! {" + for file in *.txt; doˇ + "}); + cx.update_editor(|editor, window, cx| { + editor.newline(&Newline, window, cx); + }); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + for file in *.txt; do + ˇ + "}); + + // test correct indent after newline after case pattern + cx.set_state(indoc! {" + case \"$1\" in + start)ˇ + "}); + cx.update_editor(|editor, window, cx| { + editor.newline(&Newline, window, cx); + }); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + case \"$1\" in + start) + ˇ + "}); + + // test correct indent after newline after case pattern + cx.set_state(indoc! {" + case \"$1\" in + start) + ;; + *)ˇ + "}); + cx.update_editor(|editor, window, cx| { + editor.newline(&Newline, window, cx); + }); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + case \"$1\" in + start) + ;; + *) + ˇ + "}); + + // test correct indent after newline after function opening brace + cx.set_state(indoc! {" + function test() {ˇ} + "}); + cx.update_editor(|editor, window, cx| { + editor.newline(&Newline, window, cx); + }); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + function test() { + ˇ + } + "}); + + // test no extra indent after semicolon on same line + cx.set_state(indoc! {" + echo \"test\";ˇ + "}); + cx.update_editor(|editor, window, cx| { + editor.newline(&Newline, window, cx); + }); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + echo \"test\"; + ˇ + "}); +} + +fn empty_range(row: usize, column: usize) -> Range { + let point = DisplayPoint::new(DisplayRow(row as u32), column as u32); + point..point +} + +#[track_caller] +fn assert_selection_ranges(marked_text: &str, editor: &mut Editor, cx: &mut Context) { + let (text, ranges) = marked_text_ranges(marked_text, true); + assert_eq!(editor.text(cx), text); + assert_eq!( + editor.selections.ranges(&editor.display_snapshot(cx)), + ranges + .iter() + .map(|range| MultiBufferOffset(range.start)..MultiBufferOffset(range.end)) + .collect::>(), + "Assert selections are {}", + marked_text + ); +} + +pub fn handle_signature_help_request( + cx: &mut EditorLspTestContext, + mocked_response: lsp::SignatureHelp, +) -> impl Future + use<> { + let mut request = + cx.set_request_handler::(move |_, _, _| { + let mocked_response = mocked_response.clone(); + async move { Ok(Some(mocked_response)) } + }); + + async move { + request.next().await; + } +} + +#[track_caller] +pub fn check_displayed_completions(expected: Vec<&'static str>, cx: &mut EditorLspTestContext) { + cx.update_editor(|editor, _, _| { + if let Some(CodeContextMenu::Completions(menu)) = editor.context_menu.borrow().as_ref() { + let entries = menu.entries.borrow(); + let entries = entries + .iter() + .filter_map(|entry| entry.as_match()) + .map(|m| m.string.as_str()) + .collect::>(); + assert_eq!(entries, expected); + } else { + panic!("Expected completions menu"); + } + }); +} + +#[gpui::test] +async fn test_mixed_completions_with_multi_word_snippet(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + completion_provider: Some(lsp::CompletionOptions { + ..Default::default() + }), + ..Default::default() + }, + cx, + ) + .await; + cx.lsp + .set_request_handler::(move |_, _| async move { + Ok(Some(lsp::CompletionResponse::Array(vec![ + lsp::CompletionItem { + label: "unsafe".into(), + text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit { + range: lsp::Range { + start: lsp::Position { + line: 0, + character: 9, + }, + end: lsp::Position { + line: 0, + character: 11, + }, + }, + new_text: "unsafe".to_string(), + })), + insert_text_mode: Some(lsp::InsertTextMode::AS_IS), + ..Default::default() + }, + ]))) + }); + + cx.update_editor(|editor, _, cx| { + editor.project().unwrap().update(cx, |project, cx| { + project.snippets().update(cx, |snippets, _cx| { + snippets.add_snippet_for_test( + None, + PathBuf::from("test_snippets.json"), + vec![ + Arc::new(project::snippet_provider::Snippet { + prefix: vec![ + "unlimited word count".to_string(), + "unlimit word count".to_string(), + "unlimited unknown".to_string(), + ], + body: "this is many words".to_string(), + description: Some("description".to_string()), + name: "multi-word snippet test".to_string(), + }), + Arc::new(project::snippet_provider::Snippet { + prefix: vec!["unsnip".to_string(), "@few".to_string()], + body: "fewer words".to_string(), + description: Some("alt description".to_string()), + name: "other name".to_string(), + }), + Arc::new(project::snippet_provider::Snippet { + prefix: vec!["ab aa".to_string()], + body: "abcd".to_string(), + description: None, + name: "alphabet".to_string(), + }), + ], + ); + }); + }) + }); + + let get_completions = |cx: &mut EditorLspTestContext| { + cx.update_editor(|editor, _, _| match &*editor.context_menu.borrow() { + Some(CodeContextMenu::Completions(context_menu)) => { + let entries = context_menu.entries.borrow(); + entries + .iter() + .filter_map(|entry| entry.as_match().map(|m| m.string.clone())) + .collect_vec() + } + _ => vec![], + }) + }; + + // snippets: + // @foo + // foo bar + // + // when typing: + // + // when typing: + // - if I type a symbol "open the completions with snippets only" + // - if I type a word character "open the completions menu" (if it had been open snippets only, clear it out) + // + // stuff we need: + // - filtering logic change? + // - remember how far back the completion started. + + let test_cases: &[(&str, &[&str])] = &[ + ( + "un", + &[ + "unsafe", + "unlimit word count", + "unlimited unknown", + "unlimited word count", + "unsnip", + ], + ), + ( + "u ", + &[ + "unlimit word count", + "unlimited unknown", + "unlimited word count", + ], + ), + ("u a", &["ab aa", "unsafe"]), // unsAfe + ( + "u u", + &[ + "unsafe", + "unlimit word count", + "unlimited unknown", // ranked highest among snippets + "unlimited word count", + "unsnip", + ], + ), + ("uw c", &["unlimit word count", "unlimited word count"]), + ( + "u w", + &[ + "unlimit word count", + "unlimited word count", + "unlimited unknown", + ], + ), + ("u w ", &["unlimit word count", "unlimited word count"]), + ( + "u ", + &[ + "unlimit word count", + "unlimited unknown", + "unlimited word count", + ], + ), + ("wor", &[]), + ("uf", &["unsafe"]), + ("af", &["unsafe"]), + ("afu", &[]), + ( + "ue", + &["unsafe", "unlimited unknown", "unlimited word count"], + ), + ("@", &["@few"]), + ("@few", &["@few"]), + ("@ ", &[]), + ("a@", &["@few"]), + ("a@f", &["@few", "unsafe"]), + ("a@fw", &["@few"]), + ("a", &["ab aa", "unsafe"]), + ("aa", &["ab aa"]), + ("aaa", &["ab aa"]), + ("ab", &["ab aa"]), + ("ab ", &["ab aa"]), + ("ab a", &["ab aa", "unsafe"]), + ("ab ab", &["ab aa"]), + ("ab ab aa", &["ab aa"]), + ]; + + for &(input_to_simulate, expected_completions) in test_cases { + cx.set_state("fn a() { ˇ }\n"); + for c in input_to_simulate.split("") { + cx.simulate_input(c); + cx.run_until_parked(); + } + let expected_completions = expected_completions + .iter() + .map(|s| s.to_string()) + .collect_vec(); + assert_eq!( + get_completions(&mut cx), + expected_completions, + "< actual / expected >, input = {input_to_simulate:?}", + ); + } +} + +/// Handle completion request passing a marked string specifying where the completion +/// should be triggered from using '|' character, what range should be replaced, and what completions +/// should be returned using '<' and '>' to delimit the range. +/// +/// Also see `handle_completion_request_with_insert_and_replace`. +#[track_caller] +pub fn handle_completion_request( + marked_string: &str, + completions: Vec<&'static str>, + is_incomplete: bool, + counter: Arc, + cx: &mut EditorLspTestContext, +) -> impl Future { + let complete_from_marker: TextRangeMarker = '|'.into(); + let replace_range_marker: TextRangeMarker = ('<', '>').into(); + let (_, mut marked_ranges) = marked_text_ranges_by( + marked_string, + vec![complete_from_marker.clone(), replace_range_marker.clone()], + ); + + let complete_from_position = cx.to_lsp(MultiBufferOffset( + marked_ranges.remove(&complete_from_marker).unwrap()[0].start, + )); + let range = marked_ranges.remove(&replace_range_marker).unwrap()[0].clone(); + let replace_range = + cx.to_lsp_range(MultiBufferOffset(range.start)..MultiBufferOffset(range.end)); + + let mut request = + cx.set_request_handler::(move |url, params, _| { + let completions = completions.clone(); + counter.fetch_add(1, atomic::Ordering::Release); + async move { + assert_eq!(params.text_document_position.text_document.uri, url.clone()); + assert_eq!( + params.text_document_position.position, + complete_from_position + ); + Ok(Some(lsp::CompletionResponse::List(lsp::CompletionList { + is_incomplete, + item_defaults: None, + items: completions + .iter() + .map(|completion_text| lsp::CompletionItem { + label: completion_text.to_string(), + text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit { + range: replace_range, + new_text: completion_text.to_string(), + })), + ..Default::default() + }) + .collect(), + }))) + } + }); + + async move { + request.next().await; + } +} + +/// Similar to `handle_completion_request`, but a [`CompletionTextEdit::InsertAndReplace`] will be +/// given instead, which also contains an `insert` range. +/// +/// This function uses markers to define ranges: +/// - `|` marks the cursor position +/// - `<>` marks the replace range +/// - `[]` marks the insert range (optional, defaults to `replace_range.start..cursor_pos`which is what Rust-Analyzer provides) +pub fn handle_completion_request_with_insert_and_replace( + cx: &mut EditorLspTestContext, + marked_string: &str, + completions: Vec<(&'static str, &'static str)>, // (label, new_text) + counter: Arc, +) -> impl Future { + let complete_from_marker: TextRangeMarker = '|'.into(); + let replace_range_marker: TextRangeMarker = ('<', '>').into(); + let insert_range_marker: TextRangeMarker = ('{', '}').into(); + + let (_, mut marked_ranges) = marked_text_ranges_by( + marked_string, + vec![ + complete_from_marker.clone(), + replace_range_marker.clone(), + insert_range_marker.clone(), + ], + ); + + let complete_from_position = cx.to_lsp(MultiBufferOffset( + marked_ranges.remove(&complete_from_marker).unwrap()[0].start, + )); + let range = marked_ranges.remove(&replace_range_marker).unwrap()[0].clone(); + let replace_range = + cx.to_lsp_range(MultiBufferOffset(range.start)..MultiBufferOffset(range.end)); + + let insert_range = match marked_ranges.remove(&insert_range_marker) { + Some(ranges) if !ranges.is_empty() => { + let range1 = ranges[0].clone(); + cx.to_lsp_range(MultiBufferOffset(range1.start)..MultiBufferOffset(range1.end)) + } + _ => lsp::Range { + start: replace_range.start, + end: complete_from_position, + }, + }; + + let mut request = + cx.set_request_handler::(move |url, params, _| { + let completions = completions.clone(); + counter.fetch_add(1, atomic::Ordering::Release); + async move { + assert_eq!(params.text_document_position.text_document.uri, url.clone()); + assert_eq!( + params.text_document_position.position, complete_from_position, + "marker `|` position doesn't match", + ); + Ok(Some(lsp::CompletionResponse::Array( + completions + .iter() + .map(|(label, new_text)| lsp::CompletionItem { + label: label.to_string(), + text_edit: Some(lsp::CompletionTextEdit::InsertAndReplace( + lsp::InsertReplaceEdit { + insert: insert_range, + replace: replace_range, + new_text: new_text.to_string(), + }, + )), + ..Default::default() + }) + .collect(), + ))) + } + }); + + async move { + request.next().await; + } +} + +fn handle_resolve_completion_request( + cx: &mut EditorLspTestContext, + edits: Option>, +) -> impl Future { + let edits = edits.map(|edits| { + edits + .iter() + .map(|(marked_string, new_text)| { + let (_, marked_ranges) = marked_text_ranges(marked_string, false); + let replace_range = cx.to_lsp_range( + MultiBufferOffset(marked_ranges[0].start) + ..MultiBufferOffset(marked_ranges[0].end), + ); + lsp::TextEdit::new(replace_range, new_text.to_string()) + }) + .collect::>() + }); + + let mut request = + cx.set_request_handler::(move |_, _, _| { + let edits = edits.clone(); + async move { + Ok(lsp::CompletionItem { + additional_text_edits: edits, + ..Default::default() + }) + } + }); + + async move { + request.next().await; + } +} + +pub(crate) fn update_test_language_settings( + cx: &mut TestAppContext, + f: &dyn Fn(&mut AllLanguageSettingsContent), +) { + cx.update(|cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, &|settings: &mut SettingsContent| { + f(&mut settings.project.all_languages) + }); + }); + }); +} + +pub(crate) fn update_test_project_settings( + cx: &mut TestAppContext, + f: &dyn Fn(&mut ProjectSettingsContent), +) { + cx.update(|cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| f(&mut settings.project)); + }); + }); +} + +pub(crate) fn update_test_editor_settings( + cx: &mut TestAppContext, + f: &dyn Fn(&mut EditorSettingsContent), +) { + cx.update(|cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| f(&mut settings.editor)); + }) + }) +} + +pub(crate) fn init_test(cx: &mut TestAppContext, f: fn(&mut AllLanguageSettingsContent)) { + cx.update(|cx| { + assets::Assets.load_test_fonts(cx); + let store = SettingsStore::test(cx); + cx.set_global(store); + theme_settings::init(theme::LoadThemes::JustBase, cx); + release_channel::init(semver::Version::new(0, 0, 0), cx); + crate::init(cx); + }); + zlog::init_test(); + update_test_language_settings(cx, &f); +} + +#[track_caller] +fn assert_hunk_revert( + not_reverted_text_with_selections: &str, + expected_hunk_statuses_before: Vec, + expected_reverted_text_with_selections: &str, + base_text: &str, + cx: &mut EditorLspTestContext, +) { + cx.set_state(not_reverted_text_with_selections); + cx.set_head_text(base_text); + cx.executor().run_until_parked(); + + let actual_hunk_statuses_before = cx.update_editor(|editor, window, cx| { + let snapshot = editor.snapshot(window, cx); + let reverted_hunk_statuses = snapshot + .buffer_snapshot() + .diff_hunks_in_range(MultiBufferOffset(0)..snapshot.buffer_snapshot().len()) + .map(|hunk| hunk.status().kind) + .collect::>(); + + editor.git_restore(&Default::default(), window, cx); + reverted_hunk_statuses + }); + cx.executor().run_until_parked(); + cx.assert_editor_state(expected_reverted_text_with_selections); + assert_eq!(actual_hunk_statuses_before, expected_hunk_statuses_before); +} + +#[gpui::test(iterations = 10)] +async fn test_pulling_diagnostics(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let diagnostic_requests = Arc::new(AtomicUsize::new(0)); + let counter = diagnostic_requests.clone(); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/a"), + json!({ + "first.rs": "fn main() { let a = 5; }", + "second.rs": "// Test file", + }), + ) + .await; + + let project = Project::test(fs, [path!("/a").as_ref()], cx).await; + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(*window, cx); + + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(rust_lang()); + let mut fake_servers = language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + capabilities: lsp::ServerCapabilities { + diagnostic_provider: Some(lsp::DiagnosticServerCapabilities::Options( + lsp::DiagnosticOptions { + identifier: None, + inter_file_dependencies: true, + workspace_diagnostics: true, + work_done_progress_options: Default::default(), + }, + )), + ..Default::default() + }, + ..Default::default() + }, + ); + + let editor = workspace + .update_in(cx, |workspace, window, cx| { + workspace.open_abs_path( + PathBuf::from(path!("/a/first.rs")), + OpenOptions::default(), + window, + cx, + ) + }) + .await + .unwrap() + .downcast::() + .unwrap(); + let fake_server = fake_servers.next().await.unwrap(); + let server_id = fake_server.server.server_id(); + let mut first_request = fake_server + .set_request_handler::(move |params, _| { + let new_result_id = counter.fetch_add(1, atomic::Ordering::Release) + 1; + let result_id = Some(new_result_id.to_string()); + assert_eq!( + params.text_document.uri, + lsp::Uri::from_file_path(path!("/a/first.rs")).unwrap() + ); + async move { + Ok(lsp::DocumentDiagnosticReportResult::Report( + lsp::DocumentDiagnosticReport::Full(lsp::RelatedFullDocumentDiagnosticReport { + related_documents: None, + full_document_diagnostic_report: lsp::FullDocumentDiagnosticReport { + items: Vec::new(), + result_id, + }, + }), + )) + } + }); + + let ensure_result_id = |expected_result_id: Option, cx: &mut TestAppContext| { + project.update(cx, |project, cx| { + let buffer_id = editor + .read(cx) + .buffer() + .read(cx) + .as_singleton() + .expect("created a singleton buffer") + .read(cx) + .remote_id(); + let buffer_result_id = project + .lsp_store() + .read(cx) + .result_id_for_buffer_pull(server_id, buffer_id, &None, cx); + assert_eq!(expected_result_id, buffer_result_id); + }); + }; + + ensure_result_id(None, cx); + cx.executor().advance_clock(Duration::from_millis(60)); + cx.executor().run_until_parked(); + assert_eq!( + diagnostic_requests.load(atomic::Ordering::Acquire), + 1, + "Opening file should trigger diagnostic request" + ); + first_request + .next() + .await + .expect("should have sent the first diagnostics pull request"); + ensure_result_id(Some(SharedString::new_static("1")), cx); + + // Editing should trigger diagnostics + editor.update_in(cx, |editor, window, cx| { + editor.handle_input("2", window, cx) + }); + cx.executor().advance_clock(Duration::from_millis(60)); + cx.executor().run_until_parked(); + assert_eq!( + diagnostic_requests.load(atomic::Ordering::Acquire), + 2, + "Editing should trigger diagnostic request" + ); + ensure_result_id(Some(SharedString::new_static("2")), cx); + + // Moving cursor should not trigger diagnostic request + editor.update_in(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_ranges([Point::new(0, 0)..Point::new(0, 0)]) + }); + }); + cx.executor().advance_clock(Duration::from_millis(60)); + cx.executor().run_until_parked(); + assert_eq!( + diagnostic_requests.load(atomic::Ordering::Acquire), + 2, + "Cursor movement should not trigger diagnostic request" + ); + ensure_result_id(Some(SharedString::new_static("2")), cx); + // Multiple rapid edits should be debounced + for _ in 0..5 { + editor.update_in(cx, |editor, window, cx| { + editor.handle_input("x", window, cx) + }); + } + cx.executor().advance_clock(Duration::from_millis(60)); + cx.executor().run_until_parked(); + + let final_requests = diagnostic_requests.load(atomic::Ordering::Acquire); + assert!( + final_requests <= 4, + "Multiple rapid edits should be debounced (got {final_requests} requests)", + ); + ensure_result_id(Some(SharedString::new(final_requests.to_string())), cx); +} + +#[gpui::test] +async fn test_add_selection_after_moving_with_multiple_cursors(cx: &mut TestAppContext) { + // Regression test for issue #11671 + // Previously, adding a cursor after moving multiple cursors would reset + // the cursor count instead of adding to the existing cursors. + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + + // Create a simple buffer with cursor at start + cx.set_state(indoc! {" + ˇaaaa + bbbb + cccc + dddd + eeee + ffff + gggg + hhhh"}); + + // Add 2 cursors below (so we have 3 total) + cx.update_editor(|editor, window, cx| { + editor.add_selection_below(&Default::default(), window, cx); + editor.add_selection_below(&Default::default(), window, cx); + }); + + // Verify we have 3 cursors + let initial_count = cx.update_editor(|editor, _, _| editor.selections.count()); + assert_eq!( + initial_count, 3, + "Should have 3 cursors after adding 2 below" + ); + + // Move down one line + cx.update_editor(|editor, window, cx| { + editor.move_down(&MoveDown, window, cx); + }); + + // Add another cursor below + cx.update_editor(|editor, window, cx| { + editor.add_selection_below(&Default::default(), window, cx); + }); + + // Should now have 4 cursors (3 original + 1 new) + let final_count = cx.update_editor(|editor, _, _| editor.selections.count()); + assert_eq!( + final_count, 4, + "Should have 4 cursors after moving and adding another" + ); +} + +#[gpui::test] +async fn test_add_selection_skip_soft_wrap_option(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + cx.set_state(indoc!( + r#"ˇThis is a very long line that will be wrapped when soft wrapping is enabled + Second line here"# + )); + + cx.update_editor(|editor, window, cx| { + // Enable soft wrapping with a narrow width to force soft wrapping and + // confirm that more than 2 rows are being displayed. + editor.set_wrap_width(Some(100.0.into()), cx); + assert!(editor.display_text(cx).lines().count() > 2); + + editor.add_selection_below( + &AddSelectionBelow { + skip_soft_wrap: true, + }, + window, + cx, + ); + + assert_eq!( + display_ranges(editor, cx), + &[ + DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 0), + DisplayPoint::new(DisplayRow(8), 0)..DisplayPoint::new(DisplayRow(8), 0), + ] + ); + + editor.add_selection_above( + &AddSelectionAbove { + skip_soft_wrap: true, + }, + window, + cx, + ); + + assert_eq!( + display_ranges(editor, cx), + &[DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 0)] + ); + + editor.add_selection_below( + &AddSelectionBelow { + skip_soft_wrap: false, + }, + window, + cx, + ); + + assert_eq!( + display_ranges(editor, cx), + &[ + DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 0), + DisplayPoint::new(DisplayRow(1), 0)..DisplayPoint::new(DisplayRow(1), 0), + ] + ); + + editor.add_selection_above( + &AddSelectionAbove { + skip_soft_wrap: false, + }, + window, + cx, + ); + + assert_eq!( + display_ranges(editor, cx), + &[DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 0)] + ); + }); + + // Set up text where selections are in the middle of a soft-wrapped line. + // When adding selection below with `skip_soft_wrap` set to `true`, the new + // selection should be at the same buffer column, not the same pixel + // position. + cx.set_state(indoc!( + r#"1. Very long line to show «howˇ» a wrapped line would look + 2. Very long line to show how a wrapped line would look"# + )); + + cx.update_editor(|editor, window, cx| { + // Enable soft wrapping with a narrow width to force soft wrapping and + // confirm that more than 2 rows are being displayed. + editor.set_wrap_width(Some(100.0.into()), cx); + assert!(editor.display_text(cx).lines().count() > 2); + + editor.add_selection_below( + &AddSelectionBelow { + skip_soft_wrap: true, + }, + window, + cx, + ); + + // Assert that there's now 2 selections, both selecting the same column + // range in the buffer row. + let display_map = editor.display_map.update(cx, |map, cx| map.snapshot(cx)); + let selections = editor.selections.all::(&display_map); + assert_eq!(selections.len(), 2); + assert_eq!(selections[0].start.column, selections[1].start.column); + assert_eq!(selections[0].end.column, selections[1].end.column); + }); +} + +#[gpui::test] +async fn test_insert_snippet(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + + cx.update_editor(|editor, _, cx| { + editor.project().unwrap().update(cx, |project, cx| { + project.snippets().update(cx, |snippets, _cx| { + let snippet = project::snippet_provider::Snippet { + prefix: vec![], // no prefix needed! + body: "an Unspecified".to_string(), + description: Some("shhhh it's a secret".to_string()), + name: "super secret snippet".to_string(), + }; + snippets.add_snippet_for_test( + None, + PathBuf::from("test_snippets.json"), + vec![Arc::new(snippet)], + ); + + let snippet = project::snippet_provider::Snippet { + prefix: vec![], // no prefix needed! + body: " Location".to_string(), + description: Some("the word 'location'".to_string()), + name: "location word".to_string(), + }; + snippets.add_snippet_for_test( + Some("Markdown".to_string()), + PathBuf::from("test_snippets.json"), + vec![Arc::new(snippet)], + ); + }); + }) + }); + + cx.set_state(indoc!(r#"First cursor at ˇ and second cursor at ˇ"#)); + + cx.update_editor(|editor, window, cx| { + editor.insert_snippet_at_selections( + &InsertSnippet { + language: None, + name: Some("super secret snippet".to_string()), + snippet: None, + }, + window, + cx, + ); + + // Language is specified in the action, + // so the buffer language does not need to match + editor.insert_snippet_at_selections( + &InsertSnippet { + language: Some("Markdown".to_string()), + name: Some("location word".to_string()), + snippet: None, + }, + window, + cx, + ); + + editor.insert_snippet_at_selections( + &InsertSnippet { + language: None, + name: None, + snippet: Some("$0 after".to_string()), + }, + window, + cx, + ); + }); + + cx.assert_editor_state( + r#"First cursor at an Unspecified Locationˇ after and second cursor at an Unspecified Locationˇ after"#, + ); +} + +#[gpui::test] +async fn test_inlay_hints_request_timeout(cx: &mut TestAppContext) { + use crate::inlays::inlay_hints::tests::{cached_hint_labels, init_test, visible_hint_labels}; + use settings::InlayHintSettingsContent; + use std::sync::atomic::AtomicU32; + use std::time::Duration; + + const BASE_TIMEOUT: Duration = Duration::from_secs(1); + + let request_count = Arc::new(AtomicU32::new(0)); + let closure_request_count = request_count.clone(); + + init_test(cx, &|settings| { + settings.defaults.inlay_hints = Some(InlayHintSettingsContent { + enabled: Some(true), + ..InlayHintSettingsContent::default() + }) + }); + cx.update(|cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, &|settings: &mut SettingsContent| { + settings.global_lsp_settings = Some(GlobalLspSettingsContent { + request_timeout: Some(BASE_TIMEOUT.as_secs()), + max_buffer_line_length: None, + button: Some(true), + notifications: None, + semantic_token_rules: None, + }); + }); + }); + }); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/a"), + json!({ + "main.rs": "fn main() { let a = 5; }", + }), + ) + .await; + + let project = Project::test(fs, [path!("/a").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(rust_lang()); + let mut fake_servers = language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + capabilities: lsp::ServerCapabilities { + inlay_hint_provider: Some(lsp::OneOf::Left(true)), + ..lsp::ServerCapabilities::default() + }, + initializer: Some(Box::new(move |fake_server| { + let request_count = closure_request_count.clone(); + fake_server.set_request_handler::( + move |params, cx| { + let request_count = request_count.clone(); + async move { + cx.background_executor().timer(BASE_TIMEOUT * 2).await; + let count = request_count.fetch_add(1, atomic::Ordering::Release) + 1; + assert_eq!( + params.text_document.uri, + lsp::Uri::from_file_path(path!("/a/main.rs")).unwrap(), + ); + Ok(Some(vec![lsp::InlayHint { + position: lsp::Position::new(0, 1), + label: lsp::InlayHintLabel::String(count.to_string()), + kind: None, + text_edits: None, + tooltip: None, + padding_left: None, + padding_right: None, + data: None, + }])) + } + }, + ); + })), + ..FakeLspAdapter::default() + }, + ); + + let buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/a/main.rs"), cx) + }) + .await + .unwrap(); + let editor = cx.add_window(|window, cx| Editor::for_buffer(buffer, Some(project), window, cx)); + + cx.executor().run_until_parked(); + let fake_server = fake_servers.next().await.unwrap(); + + cx.executor() + .advance_clock(BASE_TIMEOUT + Duration::from_millis(100)); + cx.executor().run_until_parked(); + editor + .update(cx, |editor, _window, cx| { + assert!( + cached_hint_labels(editor, cx).is_empty(), + "First request should time out, no hints cached" + ); + }) + .unwrap(); + + fake_server + .request::((), lsp::DEFAULT_LSP_REQUEST_TIMEOUT) + .await + .into_response() + .unwrap(); + cx.executor() + .advance_clock(BASE_TIMEOUT + Duration::from_millis(100)); + cx.executor().run_until_parked(); + editor + .update(cx, |editor, _window, cx| { + assert!( + cached_hint_labels(editor, cx).is_empty(), + "Second request should also time out with BASE_TIMEOUT, no hints cached" + ); + }) + .unwrap(); + + cx.update(|cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings.global_lsp_settings = Some(GlobalLspSettingsContent { + request_timeout: Some(BASE_TIMEOUT.as_secs() * 4), + max_buffer_line_length: None, + button: Some(true), + notifications: None, + semantic_token_rules: None, + }); + }); + }); + }); + fake_server + .request::((), lsp::DEFAULT_LSP_REQUEST_TIMEOUT) + .await + .into_response() + .unwrap(); + cx.executor() + .advance_clock(BASE_TIMEOUT * 4 + Duration::from_millis(100)); + cx.executor().run_until_parked(); + editor + .update(cx, |editor, _window, cx| { + assert_eq!( + vec!["1".to_string()], + cached_hint_labels(editor, cx), + "With extended timeout (BASE * 4), hints should arrive successfully" + ); + assert_eq!(vec!["1".to_string()], visible_hint_labels(editor, cx)); + }) + .unwrap(); +} + +#[gpui::test] +async fn test_click_on_parameter_inlay_hint_places_cursor_correctly(cx: &mut TestAppContext) { + use crate::inlays::inlay_hints::tests::{cached_hint_labels, visible_hint_labels}; + + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + inlay_hint_provider: Some(lsp::OneOf::Left(true)), + ..Default::default() + }, + cx, + ) + .await; + + cx.update(|_, cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, &|settings: &mut SettingsContent| { + settings.project.all_languages.defaults.inlay_hints = + Some(InlayHintSettingsContent { + enabled: Some(true), + show_parameter_hints: Some(true), + show_type_hints: Some(true), + edit_debounce_ms: Some(0), + scroll_debounce_ms: Some(0), + ..Default::default() + }) + }); + }); + }); + + cx.set_state("fn foo(value: i32) {} fn main() { foo(ˇ42); }"); + + // Buffer: `fn foo(value: i32) {} fn main() { foo(42); }` + // The parameter hint "value:" appears before "42" + let hint_start_offset = cx.ranges("fn foo(value: i32) {} fn main() { foo(ˇ42); }")[0].start; + let hint_position = cx.to_lsp(MultiBufferOffset(hint_start_offset)); + let hint_label = "value:"; + let expected_uri = cx.buffer_lsp_url.clone(); + cx.lsp + .set_request_handler::(move |params, _| { + let expected_uri = expected_uri.clone(); + async move { + assert_eq!(params.text_document.uri, expected_uri); + Ok(Some(vec![lsp::InlayHint { + position: hint_position, + label: lsp::InlayHintLabel::String(hint_label.to_string()), + kind: Some(lsp::InlayHintKind::PARAMETER), + text_edits: None, + tooltip: None, + padding_left: None, + padding_right: Some(true), + data: None, + }])) + } + }) + .next() + .await; + cx.background_executor.run_until_parked(); + + cx.update_editor(|editor, _window, cx| { + let expected_labels = vec!["value: ".to_string()]; + assert_eq!(expected_labels, cached_hint_labels(editor, cx)); + assert_eq!(expected_labels, visible_hint_labels(editor, cx)); + }); + + // The cursor is at `4` in `42`. The parameter hint "value: " appears just + // before it in display space. We'll click a few characters to the left of + // the cursor position to land inside the inlay hint text. + let cursor_display_point = cx.update_editor(|editor, _window, cx| { + editor + .selections + .newest_display(&editor.display_snapshot(cx)) + .head() + }); + let cursor_pixel = cx.pixel_position_for(cursor_display_point); + let em_width = + cx.update_editor(|editor, _, _| editor.last_position_map.as_ref().unwrap().em_layout_width); + // Click 3 characters to the left of the cursor, which lands inside the + // "value: " inlay hint text. + let click_position = gpui::Point { + x: cursor_pixel.x - em_width * 3.0, + y: cursor_pixel.y, + }; + cx.simulate_click(click_position, Modifiers::none()); + cx.background_executor.run_until_parked(); + + // The cursor should be placed after the `(`, at the `4` in `42`, + // NOT before the `(`. + cx.assert_editor_state("fn foo(value: i32) {} fn main() { foo(ˇ42); }"); +} + +#[gpui::test] +async fn test_newline_replacement_in_single_line(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let (editor, cx) = cx.add_window_view(Editor::single_line); + editor.update_in(cx, |editor, window, cx| { + editor.set_text("oops\n\nwow\n", window, cx) + }); + cx.run_until_parked(); + editor.update(cx, |editor, cx| { + assert_eq!(editor.display_text(cx), "oops⋯⋯wow⋯"); + }); + editor.update(cx, |editor, cx| { + editor.edit([(MultiBufferOffset(3)..MultiBufferOffset(5), "")], cx) + }); + cx.run_until_parked(); + editor.update(cx, |editor, cx| { + assert_eq!(editor.display_text(cx), "oop⋯wow⋯"); + }); +} + +#[gpui::test] +async fn test_non_utf_8_opens(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + cx.update(|cx| { + register_project_item::(cx); + }); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree("/root1", json!({})).await; + fs.insert_file("/root1/one.pdf", vec![0xff, 0xfe, 0xfd]) + .await; + + let project = Project::test(fs, ["/root1".as_ref()], cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); + + let worktree_id = project.update(cx, |project, cx| { + project.worktrees(cx).next().unwrap().read(cx).id() + }); + + let handle = workspace + .update_in(cx, |workspace, window, cx| { + let project_path = (worktree_id, rel_path("one.pdf")); + workspace.open_path(project_path, None, true, window, cx) + }) + .await + .unwrap(); + // The test file content `vec![0xff, 0xfe, ...]` starts with a UTF-16 LE BOM. + // Previously, this fell back to `InvalidItemView` because it wasn't valid UTF-8. + // With auto-detection enabled, this is now recognized as UTF-16 and opens in the Editor. + assert_eq!(handle.to_any_view().entity_type(), TypeId::of::()); +} + +#[gpui::test] +async fn test_select_next_prev_syntax_node(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let language = Arc::new(Language::new( + LanguageConfig::default(), + Some(tree_sitter_rust::LANGUAGE.into()), + )); + + // Test hierarchical sibling navigation + let text = r#" + fn outer() { + if condition { + let a = 1; + } + let b = 2; + } + + fn another() { + let c = 3; + } + "#; + + let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(language, cx)); + let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + let (editor, cx) = cx.add_window_view(|window, cx| build_editor(buffer, window, cx)); + + // Wait for parsing to complete + editor + .condition::(cx, |editor, cx| !editor.buffer.read(cx).is_parsing(cx)) + .await; + + editor.update_in(cx, |editor, window, cx| { + // Start by selecting "let a = 1;" inside the if block + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(3), 16)..DisplayPoint::new(DisplayRow(3), 26) + ]); + }); + + let initial_selection = editor + .selections + .display_ranges(&editor.display_snapshot(cx)); + assert_eq!(initial_selection.len(), 1, "Should have one selection"); + + // Test select next sibling - should move up levels to find the next sibling + // Since "let a = 1;" has no siblings in the if block, it should move up + // to find "let b = 2;" which is a sibling of the if block + editor.select_next_syntax_node(&SelectNextSyntaxNode, window, cx); + let next_selection = editor + .selections + .display_ranges(&editor.display_snapshot(cx)); + + // Should have a selection and it should be different from the initial + assert_eq!( + next_selection.len(), + 1, + "Should have one selection after next" + ); + assert_ne!( + next_selection[0], initial_selection[0], + "Next sibling selection should be different" + ); + + // Test hierarchical navigation by going to the end of the current function + // and trying to navigate to the next function + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(5), 12)..DisplayPoint::new(DisplayRow(5), 22) + ]); + }); + + editor.select_next_syntax_node(&SelectNextSyntaxNode, window, cx); + let function_next_selection = editor + .selections + .display_ranges(&editor.display_snapshot(cx)); + + // Should move to the next function + assert_eq!( + function_next_selection.len(), + 1, + "Should have one selection after function next" + ); + + // Test select previous sibling navigation + editor.select_prev_syntax_node(&SelectPreviousSyntaxNode, window, cx); + let prev_selection = editor + .selections + .display_ranges(&editor.display_snapshot(cx)); + + // Should have a selection and it should be different + assert_eq!( + prev_selection.len(), + 1, + "Should have one selection after prev" + ); + assert_ne!( + prev_selection[0], function_next_selection[0], + "Previous sibling selection should be different from next" + ); + }); +} + +#[gpui::test] +async fn test_next_prev_document_highlight(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + cx.set_state( + "let ˇvariable = 42; +let another = variable + 1; +let result = variable * 2;", + ); + + // Set up document highlights manually (simulating LSP response) + cx.update_editor(|editor, _window, cx| { + let buffer_snapshot = editor.buffer().read(cx).snapshot(cx); + + // Create highlights for "variable" occurrences + let highlight_ranges = [ + Point::new(0, 4)..Point::new(0, 12), // First "variable" + Point::new(1, 14)..Point::new(1, 22), // Second "variable" + Point::new(2, 13)..Point::new(2, 21), // Third "variable" + ]; + + let anchor_ranges: Vec<_> = highlight_ranges + .iter() + .map(|range| range.clone().to_anchors(&buffer_snapshot)) + .collect(); + + editor.highlight_background( + HighlightKey::DocumentHighlightRead, + &anchor_ranges, + |_, theme| theme.colors().editor_document_highlight_read_background, + cx, + ); + }); + + // Go to next highlight - should move to second "variable" + cx.update_editor(|editor, window, cx| { + editor.go_to_next_document_highlight(&GoToNextDocumentHighlight, window, cx); + }); + cx.assert_editor_state( + "let variable = 42; +let another = ˇvariable + 1; +let result = variable * 2;", + ); + + // Go to next highlight - should move to third "variable" + cx.update_editor(|editor, window, cx| { + editor.go_to_next_document_highlight(&GoToNextDocumentHighlight, window, cx); + }); + cx.assert_editor_state( + "let variable = 42; +let another = variable + 1; +let result = ˇvariable * 2;", + ); + + // Go to next highlight - should stay at third "variable" (no wrap-around) + cx.update_editor(|editor, window, cx| { + editor.go_to_next_document_highlight(&GoToNextDocumentHighlight, window, cx); + }); + cx.assert_editor_state( + "let variable = 42; +let another = variable + 1; +let result = ˇvariable * 2;", + ); + + // Now test going backwards from third position + cx.update_editor(|editor, window, cx| { + editor.go_to_prev_document_highlight(&GoToPreviousDocumentHighlight, window, cx); + }); + cx.assert_editor_state( + "let variable = 42; +let another = ˇvariable + 1; +let result = variable * 2;", + ); + + // Go to previous highlight - should move to first "variable" + cx.update_editor(|editor, window, cx| { + editor.go_to_prev_document_highlight(&GoToPreviousDocumentHighlight, window, cx); + }); + cx.assert_editor_state( + "let ˇvariable = 42; +let another = variable + 1; +let result = variable * 2;", + ); + + // Go to previous highlight - should stay on first "variable" + cx.update_editor(|editor, window, cx| { + editor.go_to_prev_document_highlight(&GoToPreviousDocumentHighlight, window, cx); + }); + cx.assert_editor_state( + "let ˇvariable = 42; +let another = variable + 1; +let result = variable * 2;", + ); +} + +#[gpui::test] +async fn test_paste_url_from_other_app_creates_markdown_link_over_selected_text( + cx: &mut gpui::TestAppContext, +) { + init_test(cx, |_| {}); + + let url = "https://zed.dev"; + + let markdown_language = Arc::new(Language::new( + LanguageConfig { + name: "Markdown".into(), + ..LanguageConfig::default() + }, + None, + )); + + let mut cx = EditorTestContext::new(cx).await; + cx.update_buffer(|buffer, cx| buffer.set_language(Some(markdown_language), cx)); + cx.set_state("Hello, «editorˇ».\nZed is «ˇgreat» (see this link: ˇ)"); + + cx.update_editor(|editor, window, cx| { + cx.write_to_clipboard(ClipboardItem::new_string(url.to_string())); + editor.paste(&Paste, window, cx); + }); + + cx.assert_editor_state(&format!( + "Hello, [editor]({url})ˇ.\nZed is [great]({url})ˇ (see this link: {url}ˇ)" + )); +} + +#[gpui::test] +async fn test_markdown_indents(cx: &mut gpui::TestAppContext) { + init_test(cx, |_| {}); + + let markdown_language = languages::language("markdown", tree_sitter_md::LANGUAGE.into()); + let mut cx = EditorTestContext::new(cx).await; + + cx.update_buffer(|buffer, cx| buffer.set_language(Some(markdown_language), cx)); + + // Case 1: Test if adding a character with multi cursors preserves nested list indents + cx.set_state(&indoc! {" + - [ ] Item 1 + - [ ] Item 1.a + - [ˇ] Item 2 + - [ˇ] Item 2.a + - [ˇ] Item 2.b + " + }); + cx.update_editor(|editor, window, cx| { + editor.handle_input("x", window, cx); + }); + cx.run_until_parked(); + cx.assert_editor_state(indoc! {" + - [ ] Item 1 + - [ ] Item 1.a + - [xˇ] Item 2 + - [xˇ] Item 2.a + - [xˇ] Item 2.b + " + }); + + // Case 2: Test adding new line after nested list continues the list with unchecked task + cx.set_state(&indoc! {" + - [ ] Item 1 + - [ ] Item 1.a + - [x] Item 2 + - [x] Item 2.a + - [x] Item 2.bˇ" + }); + cx.update_editor(|editor, window, cx| { + editor.newline(&Newline, window, cx); + }); + cx.assert_editor_state(indoc! {" + - [ ] Item 1 + - [ ] Item 1.a + - [x] Item 2 + - [x] Item 2.a + - [x] Item 2.b + - [ ] ˇ" + }); + + // Case 3: Test adding content to continued list item + cx.update_editor(|editor, window, cx| { + editor.handle_input("Item 2.c", window, cx); + }); + cx.run_until_parked(); + cx.assert_editor_state(indoc! {" + - [ ] Item 1 + - [ ] Item 1.a + - [x] Item 2 + - [x] Item 2.a + - [x] Item 2.b + - [ ] Item 2.cˇ" + }); + + // Case 4: Test adding new line after nested ordered list continues with next number + cx.set_state(indoc! {" + 1. Item 1 + 1. Item 1.a + 2. Item 2 + 1. Item 2.a + 2. Item 2.bˇ" + }); + cx.update_editor(|editor, window, cx| { + editor.newline(&Newline, window, cx); + }); + cx.assert_editor_state(indoc! {" + 1. Item 1 + 1. Item 1.a + 2. Item 2 + 1. Item 2.a + 2. Item 2.b + 3. ˇ" + }); + + // Case 5: Adding content to continued ordered list item + cx.update_editor(|editor, window, cx| { + editor.handle_input("Item 2.c", window, cx); + }); + cx.run_until_parked(); + cx.assert_editor_state(indoc! {" + 1. Item 1 + 1. Item 1.a + 2. Item 2 + 1. Item 2.a + 2. Item 2.b + 3. Item 2.cˇ" + }); + + // Case 6: Test adding new line after nested ordered list preserves indent of previous line + cx.set_state(indoc! {" + - Item 1 + - Item 1.a + - Item 1.a + ˇ"}); + cx.update_editor(|editor, window, cx| { + editor.handle_input("-", window, cx); + }); + cx.run_until_parked(); + cx.assert_editor_state(indoc! {" + - Item 1 + - Item 1.a + - Item 1.a + -ˇ"}); + + // Case 7: Test blockquote newline preserves something + cx.set_state(indoc! {" + > Item 1ˇ" + }); + cx.update_editor(|editor, window, cx| { + editor.newline(&Newline, window, cx); + }); + cx.assert_editor_state(indoc! {" + > Item 1 + ˇ" + }); +} + +#[gpui::test] +async fn test_paste_url_from_zed_copy_creates_markdown_link_over_selected_text( + cx: &mut gpui::TestAppContext, +) { + init_test(cx, |_| {}); + + let url = "https://zed.dev"; + + let markdown_language = Arc::new(Language::new( + LanguageConfig { + name: "Markdown".into(), + ..LanguageConfig::default() + }, + None, + )); + + let mut cx = EditorTestContext::new(cx).await; + cx.update_buffer(|buffer, cx| buffer.set_language(Some(markdown_language), cx)); + cx.set_state(&format!( + "Hello, editor.\nZed is great (see this link: )\n«{url}ˇ»" + )); + + cx.update_editor(|editor, window, cx| { + editor.copy(&Copy, window, cx); + }); + + cx.set_state(&format!( + "Hello, «editorˇ».\nZed is «ˇgreat» (see this link: ˇ)\n{url}" + )); + + cx.update_editor(|editor, window, cx| { + editor.paste(&Paste, window, cx); + }); + + cx.assert_editor_state(&format!( + "Hello, [editor]({url})ˇ.\nZed is [great]({url})ˇ (see this link: {url}ˇ)\n{url}" + )); +} + +#[gpui::test] +async fn test_paste_url_from_other_app_replaces_existing_url_without_creating_markdown_link( + cx: &mut gpui::TestAppContext, +) { + init_test(cx, |_| {}); + + let url = "https://zed.dev"; + + let markdown_language = Arc::new(Language::new( + LanguageConfig { + name: "Markdown".into(), + ..LanguageConfig::default() + }, + None, + )); + + let mut cx = EditorTestContext::new(cx).await; + cx.update_buffer(|buffer, cx| buffer.set_language(Some(markdown_language), cx)); + cx.set_state("Please visit zed's homepage: «https://www.apple.comˇ»"); + + cx.update_editor(|editor, window, cx| { + cx.write_to_clipboard(ClipboardItem::new_string(url.to_string())); + editor.paste(&Paste, window, cx); + }); + + cx.assert_editor_state(&format!("Please visit zed's homepage: {url}ˇ")); +} + +#[gpui::test] +async fn test_paste_plain_text_from_other_app_replaces_selection_without_creating_markdown_link( + cx: &mut gpui::TestAppContext, +) { + init_test(cx, |_| {}); + + let text = "Awesome"; + + let markdown_language = Arc::new(Language::new( + LanguageConfig { + name: "Markdown".into(), + ..LanguageConfig::default() + }, + None, + )); + + let mut cx = EditorTestContext::new(cx).await; + cx.update_buffer(|buffer, cx| buffer.set_language(Some(markdown_language), cx)); + cx.set_state("Hello, «editorˇ».\nZed is «ˇgreat»"); + + cx.update_editor(|editor, window, cx| { + cx.write_to_clipboard(ClipboardItem::new_string(text.to_string())); + editor.paste(&Paste, window, cx); + }); + + cx.assert_editor_state(&format!("Hello, {text}ˇ.\nZed is {text}ˇ")); +} + +#[gpui::test] +async fn test_paste_text_with_scheme_like_prefix_replaces_selection_without_creating_markdown_link( + cx: &mut gpui::TestAppContext, +) { + init_test(cx, |_| {}); + + // `url::Url::parse` accepts this as a URL with the scheme `editor`, but it + // should not be treated as one when pasting. + let text = "editor: Fix double-click bracket selection for large spans"; + + let markdown_language = Arc::new(Language::new( + LanguageConfig { + name: "Markdown".into(), + ..LanguageConfig::default() + }, + None, + )); + + let mut cx = EditorTestContext::new(cx).await; + cx.update_buffer(|buffer, cx| buffer.set_language(Some(markdown_language), cx)); + cx.set_state("«(feat on git-ui-add-info-exclude-to-context-menus) Fmtˇ»"); + + cx.update_editor(|editor, window, cx| { + cx.write_to_clipboard(ClipboardItem::new_string(text.to_string())); + editor.paste(&Paste, window, cx); + }); + + cx.assert_editor_state(&format!("{text}ˇ")); +} + +#[gpui::test] +async fn test_paste_url_from_other_app_without_creating_markdown_link_in_non_markdown_language( + cx: &mut gpui::TestAppContext, +) { + init_test(cx, |_| {}); + + let url = "https://zed.dev"; + + let markdown_language = Arc::new(Language::new( + LanguageConfig { + name: "Rust".into(), + ..LanguageConfig::default() + }, + None, + )); + + let mut cx = EditorTestContext::new(cx).await; + cx.update_buffer(|buffer, cx| buffer.set_language(Some(markdown_language), cx)); + cx.set_state("// Hello, «editorˇ».\n// Zed is «ˇgreat» (see this link: ˇ)"); + + cx.update_editor(|editor, window, cx| { + cx.write_to_clipboard(ClipboardItem::new_string(url.to_string())); + editor.paste(&Paste, window, cx); + }); + + cx.assert_editor_state(&format!( + "// Hello, {url}ˇ.\n// Zed is {url}ˇ (see this link: {url}ˇ)" + )); +} + +#[gpui::test] +async fn test_paste_url_from_other_app_creates_markdown_link_selectively_in_multi_buffer( + cx: &mut TestAppContext, +) { + init_test(cx, |_| {}); + + let url = "https://zed.dev"; + + let markdown_language = Arc::new(Language::new( + LanguageConfig { + name: "Markdown".into(), + ..LanguageConfig::default() + }, + None, + )); + + let (editor, cx) = cx.add_window_view(|window, cx| { + let multi_buffer = MultiBuffer::build_multi( + [ + ("this will embed -> link", vec![Point::row_range(0..1)]), + ("this will replace -> link", vec![Point::row_range(0..1)]), + ], + cx, + ); + let mut editor = Editor::new(EditorMode::full(), multi_buffer.clone(), None, window, cx); + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_ranges(vec![ + Point::new(0, 19)..Point::new(0, 23), + Point::new(1, 21)..Point::new(1, 25), + ]) + }); + let snapshot = multi_buffer.read(cx).snapshot(cx); + let first_buffer_id = snapshot.all_buffer_ids().next().unwrap(); + let first_buffer = multi_buffer.read(cx).buffer(first_buffer_id).unwrap(); + first_buffer.update(cx, |buffer, cx| { + buffer.set_language(Some(markdown_language.clone()), cx); + }); + + editor + }); + let mut cx = EditorTestContext::for_editor_in(editor.clone(), cx).await; + + cx.update_editor(|editor, window, cx| { + cx.write_to_clipboard(ClipboardItem::new_string(url.to_string())); + editor.paste(&Paste, window, cx); + }); + + cx.assert_editor_state(&format!( + "this will embed -> [link]({url})ˇ\nthis will replace -> {url}ˇ" + )); +} + +#[gpui::test] +async fn test_paste_image_in_markdown_saves_file_and_inserts_markdown(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let markdown_language = Arc::new(Language::new( + LanguageConfig { + name: "Markdown".into(), + ..LanguageConfig::default() + }, + None, + )); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree("/test", serde_json::json!({"test.md": ""})) + .await; + let project = Project::test(fs.clone(), [std::path::Path::new("/test")], cx).await; + let buffer = project + .update(cx, |project, cx| { + project.open_local_buffer("/test/test.md", cx) + }) + .await + .unwrap(); + buffer.update(cx, |buffer, cx| { + buffer.set_language(Some(markdown_language), cx); + }); + + let editor_window = cx.add_window(|window, cx| { + let editor = build_editor_with_project( + project, + MultiBuffer::build_from_buffer(buffer, cx), + window, + cx, + ); + window.focus(&editor.focus_handle(cx), cx); + editor + }); + cx.run_until_parked(); + let mut cx = EditorTestContext::for_editor(editor_window, cx).await; + + let png_bytes: Vec = vec![1, 2, 3, 4, 5]; + let image = gpui::Image::from_bytes(gpui::ImageFormat::Png, png_bytes.clone()); + + cx.set_state("ˇ"); + cx.update_editor(|editor, window, cx| { + cx.write_to_clipboard(ClipboardItem::new_image(&image)); + editor.paste(&Paste, window, cx); + }); + cx.run_until_parked(); + + let buffer_text = cx.buffer_text(); + assert!( + buffer_text.starts_with("![](image") && buffer_text.ends_with(".png)"), + "expected markdown image syntax, got: {buffer_text:?}" + ); + + // Cursor should land inside [] so the user can type alt text immediately. + let filename_in_parens = &buffer_text["![](".len()..buffer_text.len() - 1]; + let expected_state = format!("![ˇ]({filename_in_parens})"); + cx.assert_editor_state(&expected_state); + + let filename = &buffer_text["![](".len()..buffer_text.len() - 1]; + assert_eq!( + fs.read_file_sync(format!("/test/{filename}")).unwrap(), + png_bytes, + "image file contents should match clipboard bytes" + ); +} + +#[gpui::test] +async fn test_paste_multiple_images_in_markdown_increments_filename(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let markdown_language = Arc::new(Language::new( + LanguageConfig { + name: "Markdown".into(), + ..LanguageConfig::default() + }, + None, + )); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree("/test", serde_json::json!({"test.md": ""})) + .await; + let project = Project::test(fs.clone(), [std::path::Path::new("/test")], cx).await; + let buffer = project + .update(cx, |project, cx| { + project.open_local_buffer("/test/test.md", cx) + }) + .await + .unwrap(); + buffer.update(cx, |buffer, cx| { + buffer.set_language(Some(markdown_language), cx); + }); + + let editor_window = cx.add_window(|window, cx| { + let editor = build_editor_with_project( + project, + MultiBuffer::build_from_buffer(buffer, cx), + window, + cx, + ); + window.focus(&editor.focus_handle(cx), cx); + editor + }); + cx.run_until_parked(); + let mut cx = EditorTestContext::for_editor(editor_window, cx).await; + + let png_bytes_1: Vec = vec![1, 2, 3, 4, 5]; + let png_bytes_2: Vec = vec![6, 7, 8, 9, 10]; + let png_bytes_3: Vec = vec![11, 12, 13, 14, 15]; + let image_1 = gpui::Image::from_bytes(gpui::ImageFormat::Png, png_bytes_1.clone()); + let image_2 = gpui::Image::from_bytes(gpui::ImageFormat::Png, png_bytes_2.clone()); + let image_3 = gpui::Image::from_bytes(gpui::ImageFormat::Png, png_bytes_3.clone()); + + // Paste first image — should produce image.png + cx.set_state("ˇ"); + cx.update_editor(|editor, window, cx| { + cx.write_to_clipboard(ClipboardItem::new_image(&image_1)); + editor.paste(&Paste, window, cx); + }); + cx.run_until_parked(); + let text_after_first = cx.buffer_text(); + assert_eq!( + text_after_first, "![](image.png)", + "first paste should produce image.png" + ); + assert_eq!(fs.read_file_sync("/test/image.png").unwrap(), png_bytes_1); + + // Paste second image at end — should produce image_1.png + cx.update_editor(|editor, window, cx| { + cx.write_to_clipboard(ClipboardItem::new_image(&image_2)); + editor.paste(&Paste, window, cx); + }); + cx.run_until_parked(); + let text_after_second = cx.buffer_text(); + assert!( + text_after_second.contains("![](image_1.png)"), + "second paste should produce image_1.png, got: {text_after_second:?}" + ); + assert_eq!(fs.read_file_sync("/test/image_1.png").unwrap(), png_bytes_2); + + // Paste third image — should produce image_2.png + cx.update_editor(|editor, window, cx| { + cx.write_to_clipboard(ClipboardItem::new_image(&image_3)); + editor.paste(&Paste, window, cx); + }); + cx.run_until_parked(); + let text_after_third = cx.buffer_text(); + assert!( + text_after_third.contains("![](image_2.png)"), + "third paste should produce image_2.png, got: {text_after_third:?}" + ); + assert_eq!(fs.read_file_sync("/test/image_2.png").unwrap(), png_bytes_3); +} + +#[gpui::test] +async fn test_paste_image_in_non_markdown_does_not_insert_markdown(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let rust_language = Arc::new(Language::new( + LanguageConfig { + name: "Rust".into(), + ..LanguageConfig::default() + }, + None, + )); + + let mut cx = EditorTestContext::new(cx).await; + cx.update_buffer(|buffer, cx| buffer.set_language(Some(rust_language), cx)); + cx.set_state("ˇ"); + + let image = gpui::Image::from_bytes(gpui::ImageFormat::Png, vec![1, 2, 3]); + cx.update_editor(|editor, window, cx| { + cx.write_to_clipboard(ClipboardItem::new_image(&image)); + editor.paste(&Paste, window, cx); + }); + + cx.assert_editor_state("ˇ"); +} + +#[gpui::test] +async fn test_paste_image_in_markdown_without_open_file_falls_through(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let markdown_language = Arc::new(Language::new( + LanguageConfig { + name: "Markdown".into(), + ..LanguageConfig::default() + }, + None, + )); + + // Build an editor whose buffer has no associated file path on disk. + let (editor, cx) = cx.add_window_view(|window, cx| { + let buffer = cx.new(|cx| language::Buffer::local("", cx)); + buffer.update(cx, |buffer, cx| { + buffer.set_language(Some(markdown_language), cx); + }); + let multibuffer = MultiBuffer::build_from_buffer(buffer, cx); + build_editor(multibuffer, window, cx) + }); + let mut cx = EditorTestContext::for_editor_in(editor, cx).await; + + cx.set_state("ˇ"); + let image = gpui::Image::from_bytes(gpui::ImageFormat::Png, vec![1, 2, 3]); + cx.update_editor(|editor, window, cx| { + cx.write_to_clipboard(ClipboardItem::new_image(&image)); + editor.paste(&Paste, window, cx); + }); + + cx.assert_editor_state("ˇ"); +} + +#[gpui::test] +async fn test_paste_image_in_markdown_single_file_worktree_falls_through(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let markdown_language = Arc::new(Language::new( + LanguageConfig { + name: "Markdown".into(), + ..LanguageConfig::default() + }, + None, + )); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree("/root", serde_json::json!({"test.md": ""})) + .await; + let project = Project::test(fs.clone(), [std::path::Path::new("/root/test.md")], cx).await; + let buffer = project + .update(cx, |project, cx| { + project.open_local_buffer("/root/test.md", cx) + }) + .await + .unwrap(); + buffer.update(cx, |buffer, cx| { + buffer.set_language(Some(markdown_language), cx); + }); + + let editor_window = cx.add_window(|window, cx| { + let editor = build_editor_with_project( + project, + MultiBuffer::build_from_buffer(buffer, cx), + window, + cx, + ); + window.focus(&editor.focus_handle(cx), cx); + editor + }); + cx.run_until_parked(); + let mut cx = EditorTestContext::for_editor(editor_window, cx).await; + + cx.set_state("ˇ"); + let image = gpui::Image::from_bytes(gpui::ImageFormat::Png, vec![1, 2, 3]); + cx.update_editor(|editor, window, cx| { + cx.write_to_clipboard(ClipboardItem::new_image(&image)); + editor.paste(&Paste, window, cx); + }); + cx.run_until_parked(); + + cx.assert_editor_state("ˇ"); + assert!( + fs.read_file_sync("/root/image.png").is_err(), + "no image file should be created for a single-file worktree" + ); +} + +#[gpui::test] +async fn test_format_echoing_received_line_endings_keeps_cursor(cx: &mut TestAppContext) { + init_test(cx, |settings| { + settings.defaults.ensure_final_newline_on_save = Some(false); + }); + + let lf_content = "// one\nstruct Foo {\n bar: Bar,\n}\n\n// two\nstruct Bar {\n foobar:u32,\n}\n\nfn main() {\n let foo = 1;\n}\n"; + let crlf_content = lf_content.replace('\n', "\r\n"); + + let fs = FakeFs::new(cx.executor()); + fs.insert_file(path!("/file.rs"), crlf_content.clone().into()) + .await; + + let project = Project::test(fs, [path!("/file.rs").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(rust_lang()); + + let server_side_texts = Arc::new(Mutex::new(Vec::::new())); + let mut fake_servers = language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + capabilities: lsp::ServerCapabilities { + document_formatting_provider: Some(lsp::OneOf::Left(true)), + ..Default::default() + }, + initializer: Some({ + let server_side_texts = server_side_texts.clone(); + Box::new(move |fake_server| { + fake_server.handle_notification::({ + let server_side_texts = server_side_texts.clone(); + move |params, _| { + server_side_texts.lock().push(params.text_document.text); + } + }); + }) + }), + ..FakeLspAdapter::default() + }, + ); + + let buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/file.rs"), cx) + }) + .await + .unwrap(); + + let multi_buffer = cx.new(|cx| MultiBuffer::singleton(buffer.clone(), cx)); + let (editor, cx) = cx.add_window_view(|window, cx| { + build_editor_with_project(project.clone(), multi_buffer, window, cx) + }); + + let fake_server = fake_servers.next().await.unwrap(); + cx.executor().run_until_parked(); + + editor.update_in(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::default(), window, cx, |s| { + s.select_ranges([Point::new(2, 4)..Point::new(2, 4)]); + }); + }); + + let server_side_text = server_side_texts.lock().last().cloned().unwrap(); + assert_eq!( + server_side_text, crlf_content, + "server should have received the CRLF text on DidOpenTextDocument", + ); + + let formatted_text = server_side_text.replace("foobar:u32", "foobar: u32"); + fake_server.set_request_handler::(move |_, _| { + let formatted_text = formatted_text.clone(); + async move { + Ok(Some(vec![lsp::TextEdit { + range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(13, 0)), + new_text: formatted_text, + }])) + } + }); + + let save = editor + .update_in(cx, |editor, window, cx| { + editor.save( + SaveOptions { + format: true, + force_format: false, + autosave: false, + }, + project.clone(), + window, + cx, + ) + }) + .unwrap(); + save.await; + + assert_eq!( + editor.update(cx, |editor, cx| editor.text(cx)), + lf_content.replace("foobar:u32", "foobar: u32"), + "only the minimal formatting change should be applied", + ); + editor.update(cx, |editor, cx| { + let snapshot = editor.display_snapshot(cx); + let cursor = editor.selections.newest::(&snapshot).head(); + assert_eq!( + cursor, + Point::new(2, 4), + "whole-document echo of the received line endings must not move the cursor", + ); + }); +} + +#[gpui::test] +async fn test_race_in_multibuffer_save(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/project"), + json!({ + "first.rs": "# First Document\nSome content here.", + "second.rs": "Plain text content for second file.", + }), + ) + .await; + + let project = Project::test(fs, [path!("/project").as_ref()], cx).await; + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(*window, cx); + + let language = rust_lang(); + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(language.clone()); + let mut fake_servers = language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + ..FakeLspAdapter::default() + }, + ); + + let buffer1 = project + .update(cx, |project, cx| { + project.open_local_buffer(PathBuf::from(path!("/project/first.rs")), cx) + }) + .await + .unwrap(); + let buffer2 = project + .update(cx, |project, cx| { + project.open_local_buffer(PathBuf::from(path!("/project/second.rs")), cx) + }) + .await + .unwrap(); + + let multi_buffer = cx.new(|cx| { + let mut multi_buffer = MultiBuffer::new(Capability::ReadWrite); + multi_buffer.set_excerpts_for_path( + PathKey::for_buffer(&buffer1, cx), + buffer1.clone(), + [Point::zero()..buffer1.read(cx).max_point()], + 3, + cx, + ); + multi_buffer.set_excerpts_for_path( + PathKey::for_buffer(&buffer2, cx), + buffer2.clone(), + [Point::zero()..buffer1.read(cx).max_point()], + 3, + cx, + ); + multi_buffer + }); + + let (editor, cx) = cx.add_window_view(|window, cx| { + Editor::new( + EditorMode::full(), + multi_buffer, + Some(project.clone()), + window, + cx, + ) + }); + + let fake_language_server = fake_servers.next().await.unwrap(); + + buffer1.update(cx, |buffer, cx| buffer.edit([(0..0, "hello!")], None, cx)); + + let save = editor.update_in(cx, |editor, window, cx| { + assert!(editor.is_dirty(cx)); + + editor.save( + SaveOptions { + format: true, + force_format: false, + autosave: true, + }, + project, + window, + cx, + ) + }); + let (start_edit_tx, start_edit_rx) = oneshot::channel(); + let (done_edit_tx, done_edit_rx) = oneshot::channel(); + let mut done_edit_rx = Some(done_edit_rx); + let mut start_edit_tx = Some(start_edit_tx); + + fake_language_server.set_request_handler::(move |_, _| { + start_edit_tx.take().unwrap().send(()).unwrap(); + let done_edit_rx = done_edit_rx.take().unwrap(); + async move { + done_edit_rx.await.unwrap(); + Ok(None) + } + }); + + start_edit_rx.await.unwrap(); + buffer2 + .update(cx, |buffer, cx| buffer.edit([(0..0, "world!")], None, cx)) + .unwrap(); + + done_edit_tx.send(()).unwrap(); + + save.await.unwrap(); + cx.update(|_, cx| assert!(editor.is_dirty(cx))); +} + +#[gpui::test] +fn test_duplicate_line_up_on_last_line_without_newline(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let editor = cx.add_window(|window, cx| { + let buffer = MultiBuffer::build_simple("line1\nline2", cx); + build_editor(buffer, window, cx) + }); + + editor + .update(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(1), 0)..DisplayPoint::new(DisplayRow(1), 0) + ]) + }); + + editor.duplicate_line_up(&DuplicateLineUp, window, cx); + + assert_eq!( + editor.display_text(cx), + "line1\nline2\nline2", + "Duplicating last line upward should create duplicate above, not on same line" + ); + + assert_eq!( + editor + .selections + .display_ranges(&editor.display_snapshot(cx)), + vec![DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 0)], + "Selection should move to the duplicated line" + ); + }) + .unwrap(); +} + +#[gpui::test] +async fn test_copy_line_without_trailing_newline(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + cx.set_state("line1\nline2ˇ"); + + cx.update_editor(|e, window, cx| e.copy(&Copy, window, cx)); + + let clipboard_text = cx + .read_from_clipboard() + .and_then(|item| item.text().as_deref().map(str::to_string)); + + assert_eq!( + clipboard_text, + Some("line2\n".to_string()), + "Copying a line without trailing newline should include a newline" + ); + + cx.set_state("line1\nˇ"); + + cx.update_editor(|e, window, cx| e.paste(&Paste, window, cx)); + + cx.assert_editor_state("line1\nline2\nˇ"); +} + +#[gpui::test] +async fn test_multi_selection_copy_with_newline_between_copied_lines(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + cx.set_state("ˇline1\nˇline2\nˇline3\n"); + + cx.update_editor(|e, window, cx| e.copy(&Copy, window, cx)); + + let clipboard_text = cx + .read_from_clipboard() + .and_then(|item| item.text().as_deref().map(str::to_string)); + + assert_eq!( + clipboard_text, + Some("line1\nline2\nline3\n".to_string()), + "Copying multiple lines should include a single newline between lines" + ); + + cx.set_state("lineA\nˇ"); + + cx.update_editor(|e, window, cx| e.paste(&Paste, window, cx)); + + cx.assert_editor_state("lineA\nline1\nline2\nline3\nˇ"); +} + +#[gpui::test] +async fn test_multi_selection_cut_with_newline_between_copied_lines(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + cx.set_state("ˇline1\nˇline2\nˇline3\n"); + + cx.update_editor(|e, window, cx| e.cut(&Cut, window, cx)); + + let clipboard_text = cx + .read_from_clipboard() + .and_then(|item| item.text().as_deref().map(str::to_string)); + + assert_eq!( + clipboard_text, + Some("line1\nline2\nline3\n".to_string()), + "Copying multiple lines should include a single newline between lines" + ); + + cx.set_state("lineA\nˇ"); + + cx.update_editor(|e, window, cx| e.paste(&Paste, window, cx)); + + cx.assert_editor_state("lineA\nline1\nline2\nline3\nˇ"); +} + +#[gpui::test] +async fn test_end_of_editor_context(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + cx.set_state("line1\nline2ˇ"); + cx.update_editor(|e, window, cx| { + e.set_mode(EditorMode::SingleLine); + assert!(!e.key_context(window, cx).contains("start_of_input")); + assert!(e.key_context(window, cx).contains("end_of_input")); + }); + cx.set_state("ˇline1\nline2"); + cx.update_editor(|e, window, cx| { + e.set_mode(EditorMode::SingleLine); + assert!(e.key_context(window, cx).contains("start_of_input")); + assert!(!e.key_context(window, cx).contains("end_of_input")); + }); + cx.set_state("line1ˇ\nline2"); + cx.update_editor(|e, window, cx| { + e.set_mode(EditorMode::SingleLine); + assert!(!e.key_context(window, cx).contains("start_of_input")); + assert!(!e.key_context(window, cx).contains("end_of_input")); + }); + + cx.set_state("line1\nline2ˇ"); + cx.update_editor(|e, window, cx| { + e.set_mode(EditorMode::AutoHeight { + min_lines: 1, + max_lines: Some(4), + }); + assert!(!e.key_context(window, cx).contains("start_of_input")); + assert!(e.key_context(window, cx).contains("end_of_input")); + }); + cx.set_state("ˇline1\nline2"); + cx.update_editor(|e, window, cx| { + e.set_mode(EditorMode::AutoHeight { + min_lines: 1, + max_lines: Some(4), + }); + assert!(e.key_context(window, cx).contains("start_of_input")); + assert!(!e.key_context(window, cx).contains("end_of_input")); + }); + cx.set_state("line1ˇ\nline2"); + cx.update_editor(|e, window, cx| { + e.set_mode(EditorMode::AutoHeight { + min_lines: 1, + max_lines: Some(4), + }); + assert!(!e.key_context(window, cx).contains("start_of_input")); + assert!(!e.key_context(window, cx).contains("end_of_input")); + }); +} + +#[gpui::test] +async fn test_sticky_scroll(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + + let buffer = indoc! {" + ˇfn foo() { + let abc = 123; + } + struct Bar; + impl Bar { + fn new() -> Self { + Self + } + } + fn baz() { + } + "}; + cx.set_state(&buffer); + + cx.update_editor(|e, _, cx| { + e.buffer() + .read(cx) + .as_singleton() + .unwrap() + .update(cx, |buffer, cx| { + buffer.set_language(Some(rust_lang()), cx); + }) + }); + + let mut sticky_headers = |offset: ScrollOffset| { + cx.update_editor(|e, window, cx| { + e.scroll(gpui::Point { x: 0., y: offset }, window, cx); + }); + cx.run_until_parked(); + cx.update_editor(|e, window, cx| { + EditorElement::sticky_headers(&e, &e.snapshot(window, cx)) + .into_iter() + .map( + |StickyHeader { + start_point, + offset, + .. + }| { (start_point, offset) }, + ) + .collect::>() + }) + }; + + let fn_foo = Point { row: 0, column: 0 }; + let impl_bar = Point { row: 4, column: 0 }; + let fn_new = Point { row: 5, column: 4 }; + + assert_eq!(sticky_headers(0.0), vec![]); + assert_eq!(sticky_headers(0.5), vec![(fn_foo, 0.0)]); + assert_eq!(sticky_headers(1.0), vec![(fn_foo, 0.0)]); + assert_eq!(sticky_headers(1.5), vec![(fn_foo, -0.5)]); + assert_eq!(sticky_headers(2.0), vec![]); + assert_eq!(sticky_headers(2.5), vec![]); + assert_eq!(sticky_headers(3.0), vec![]); + assert_eq!(sticky_headers(3.5), vec![]); + assert_eq!(sticky_headers(4.0), vec![]); + assert_eq!(sticky_headers(4.5), vec![(impl_bar, 0.0), (fn_new, 1.0)]); + assert_eq!(sticky_headers(5.0), vec![(impl_bar, 0.0), (fn_new, 1.0)]); + assert_eq!(sticky_headers(5.5), vec![(impl_bar, 0.0), (fn_new, 0.5)]); + assert_eq!(sticky_headers(6.0), vec![(impl_bar, 0.0)]); + assert_eq!(sticky_headers(6.5), vec![(impl_bar, 0.0)]); + assert_eq!(sticky_headers(7.0), vec![(impl_bar, 0.0)]); + assert_eq!(sticky_headers(7.5), vec![(impl_bar, -0.5)]); + assert_eq!(sticky_headers(8.0), vec![]); + assert_eq!(sticky_headers(8.5), vec![]); + assert_eq!(sticky_headers(9.0), vec![]); + assert_eq!(sticky_headers(9.5), vec![]); + assert_eq!(sticky_headers(10.0), vec![]); +} + +#[gpui::test] +async fn test_sticky_scroll_with_decoration_prefix_in_item(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + + let language = Arc::new( + Language::new( + LanguageConfig { + name: "TypeScript".into(), + ..Default::default() + }, + Some(tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into()), + ) + .with_outline_query( + r#" + (class_declaration + "class" @context + name: (_) @name) @item + "#, + ) + .expect("TypeScript outline query"), + ); + + let buffer = indoc! {" + ˇ@Decorator + class Foo { + x = 1; + y = 2; + z = 3; + w = 4; + } + "}; + cx.set_state(buffer); + cx.update_editor(|e, _, cx| { + e.buffer() + .read(cx) + .as_singleton() + .unwrap() + .update(cx, |buffer, cx| { + buffer.set_language(Some(language), cx); + }) + }); + + let mut sticky_headers = |offset: ScrollOffset| { + cx.update_editor(|e, window, cx| { + e.scroll(gpui::Point { x: 0., y: offset }, window, cx); + }); + cx.run_until_parked(); + cx.update_editor(|e, window, cx| { + EditorElement::sticky_headers(&e, &e.snapshot(window, cx)) + .into_iter() + .map( + |StickyHeader { + start_point, + offset, + .. + }| { (start_point, offset) }, + ) + .collect::>() + }) + }; + + let class_foo = Point { row: 1, column: 0 }; + + assert_eq!(sticky_headers(0.0), vec![]); + assert_eq!(sticky_headers(1.5), vec![(class_foo, 0.0)]); + assert_eq!(sticky_headers(2.5), vec![(class_foo, 0.0)]); + assert_eq!(sticky_headers(5.5), vec![(class_foo, -0.5)]); + assert_eq!(sticky_headers(6.0), vec![]); + assert_eq!(sticky_headers(7.0), vec![]); +} + +#[gpui::test] +async fn test_sticky_scroll_anchors_multiline_c_signature_on_name_row(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + + let buffer = indoc! {" + ˇvoid + evdev_post_scroll(struct evdev_device *device, + usec_t time, + enum libinput_pointer_axis_source source, + const struct normalized_coords *delta) + { + const struct normalized_coords tilt_rot = { + cos(SCROLL_DELTA_TILT_ANGLE), + sin(SCROLL_DELTA_TILT_ANGLE), + }; + } + "}; + cx.set_state(buffer); + + cx.update_editor(|editor, _, cx| { + editor + .buffer() + .read(cx) + .as_singleton() + .unwrap() + .update(cx, |buffer, cx| { + buffer.set_language( + Some(languages::language("c", tree_sitter_c::LANGUAGE.into())), + cx, + ); + }) + }); + + let mut sticky_headers = |offset: ScrollOffset| { + cx.update_editor(|editor, window, cx| { + editor.scroll(gpui::Point { x: 0., y: offset }, window, cx); + }); + cx.run_until_parked(); + cx.update_editor(|editor, window, cx| { + EditorElement::sticky_headers(&editor, &editor.snapshot(window, cx)) + .into_iter() + .map( + |StickyHeader { + start_point, + offset, + .. + }| { (start_point, offset) }, + ) + .collect::>() + }) + }; + + let function_name_row = Point { row: 1, column: 0 }; + + assert_eq!(sticky_headers(1.0), vec![]); + assert_eq!(sticky_headers(1.5), vec![(function_name_row, 0.0)]); + assert_eq!(sticky_headers(5.0), vec![(function_name_row, 0.0)]); +} + +#[gpui::test] +async fn test_sticky_scroll_with_expanded_deleted_diff_hunks( + executor: BackgroundExecutor, + cx: &mut TestAppContext, +) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + + let diff_base = indoc! {" + fn foo() { + let a = 1; + let b = 2; + let c = 3; + let d = 4; + let e = 5; + } + "}; + + let buffer = indoc! {" + ˇfn foo() { + } + "}; + + cx.set_state(&buffer); + + cx.update_editor(|e, _, cx| { + e.buffer() + .read(cx) + .as_singleton() + .unwrap() + .update(cx, |buffer, cx| { + buffer.set_language(Some(rust_lang()), cx); + }) + }); + + cx.set_head_text(diff_base); + executor.run_until_parked(); + + cx.update_editor(|editor, window, cx| { + editor.expand_all_diff_hunks(&ExpandAllDiffHunks, window, cx); + }); + executor.run_until_parked(); + + // After expanding, the display should look like: + // row 0: fn foo() { + // row 1: - let a = 1; (deleted) + // row 2: - let b = 2; (deleted) + // row 3: - let c = 3; (deleted) + // row 4: - let d = 4; (deleted) + // row 5: - let e = 5; (deleted) + // row 6: } + // + // fn foo() spans display rows 0-6. Scrolling into the deleted region + // (rows 1-5) should still show fn foo() as a sticky header. + + let fn_foo = Point { row: 0, column: 0 }; + + let mut sticky_headers = |offset: ScrollOffset| { + cx.update_editor(|e, window, cx| { + e.scroll(gpui::Point { x: 0., y: offset }, window, cx); + }); + cx.run_until_parked(); + cx.update_editor(|e, window, cx| { + EditorElement::sticky_headers(&e, &e.snapshot(window, cx)) + .into_iter() + .map( + |StickyHeader { + start_point, + offset, + .. + }| { (start_point, offset) }, + ) + .collect::>() + }) + }; + + assert_eq!(sticky_headers(0.0), vec![]); + assert_eq!(sticky_headers(0.5), vec![(fn_foo, 0.0)]); + assert_eq!(sticky_headers(1.0), vec![(fn_foo, 0.0)]); + // Scrolling into deleted lines: fn foo() should still be a sticky header. + assert_eq!(sticky_headers(2.0), vec![(fn_foo, 0.0)]); + assert_eq!(sticky_headers(3.0), vec![(fn_foo, 0.0)]); + assert_eq!(sticky_headers(4.0), vec![(fn_foo, 0.0)]); + assert_eq!(sticky_headers(5.0), vec![(fn_foo, 0.0)]); + assert_eq!(sticky_headers(5.5), vec![(fn_foo, -0.5)]); + // Past the closing brace: no more sticky header. + assert_eq!(sticky_headers(6.0), vec![]); +} + +#[gpui::test] +async fn test_no_duplicated_sticky_headers(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + + cx.set_state(indoc! {" + ˇimpl Foo { fn bar() { + let x = 1; + fn baz() { + let y = 2; + } + } } + "}); + + cx.update_editor(|e, _, cx| { + e.buffer() + .read(cx) + .as_singleton() + .unwrap() + .update(cx, |buffer, cx| { + buffer.set_language(Some(rust_lang()), cx); + }) + }); + + let mut sticky_headers = |offset: ScrollOffset| { + cx.update_editor(|e, window, cx| { + e.scroll(gpui::Point { x: 0., y: offset }, window, cx); + }); + cx.run_until_parked(); + cx.update_editor(|e, window, cx| { + EditorElement::sticky_headers(&e, &e.snapshot(window, cx)) + .into_iter() + .map( + |StickyHeader { + start_point, + offset, + .. + }| { (start_point, offset) }, + ) + .collect::>() + }) + }; + + let struct_foo = Point { row: 0, column: 0 }; + let fn_baz = Point { row: 2, column: 4 }; + + assert_eq!(sticky_headers(0.0), vec![]); + assert_eq!(sticky_headers(0.5), vec![(struct_foo, 0.0)]); + assert_eq!(sticky_headers(1.0), vec![(struct_foo, 0.0)]); + assert_eq!(sticky_headers(1.5), vec![(struct_foo, 0.0), (fn_baz, 1.0)]); + assert_eq!(sticky_headers(2.0), vec![(struct_foo, 0.0), (fn_baz, 1.0)]); + assert_eq!(sticky_headers(2.5), vec![(struct_foo, 0.0), (fn_baz, 0.5)]); + assert_eq!(sticky_headers(3.0), vec![(struct_foo, 0.0)]); + assert_eq!(sticky_headers(3.5), vec![(struct_foo, 0.0)]); + assert_eq!(sticky_headers(4.0), vec![(struct_foo, 0.0)]); + assert_eq!(sticky_headers(4.5), vec![(struct_foo, -0.5)]); + assert_eq!(sticky_headers(5.0), vec![]); +} + +#[gpui::test] +async fn test_autoscroll_margin_reserves_sticky_header_space(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + update_test_editor_settings(cx, &|settings| { + settings.vertical_scroll_margin = Some(3.0); + settings.sticky_scroll = Some(settings::StickyScrollContent { + enabled: Some(true), + }); + }); + + let mut cx = EditorTestContext::new(cx).await; + cx.set_state(indoc! {" + fn demo() { + let value_01 = 1; + let value_02 = 2; + let value_03 = 3; + let value_04 = 4; + let value_05 = 5; + let value_06 = 6; + let value_07 = 7; + let value_08 = 8; + let value_09 = 9; + let value_10 = 10; + ˇlet value_11 = 11; + let value_12 = 12; + let value_13 = 13; + } + "}); + + // Use a parsed scope so the test also exercises sticky-header discovery. + cx.update_buffer(|buffer, cx| buffer.set_language(Some(rust_lang()), cx)); + cx.run_until_parked(); + + let line_height = cx.update_editor(|editor, window, cx| { + editor + .style(cx) + .text + .line_height_in_pixels(window.rem_size()) + }); + let window = cx.window; + + for visible_lines in [2., 2.5] { + cx.simulate_window_resize(window, size(px(1000.), visible_lines * line_height)); + + // Center explicitly so the starting viewport does not depend on the + // Fit calculation being tested. + cx.update_editor(|editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| { + selections.select_ranges([Point::new(11, 4)..Point::new(11, 4)]); + }); + editor.scroll_cursor_center(&ScrollCursorCenter, window, cx); + }); + cx.run_until_parked(); + + cx.update_editor(|editor, window, cx| { + let snapshot = editor.snapshot(window, cx); + assert_eq!(snapshot.scroll_position().y, 10.); + assert_eq!(EditorElement::sticky_headers(editor, &snapshot).len(), 1); + + // Row 10 is covered by the header; moving there must scroll to expose it. + editor.move_up(&MoveUp, window, cx); + }); + cx.run_until_parked(); + + cx.update_editor(|editor, window, cx| { + let snapshot = editor.snapshot(window, cx); + let cursor_row = editor + .selections + .newest_display(&snapshot.display_snapshot) + .head() + .row(); + let sticky_header_count = EditorElement::sticky_headers(editor, &snapshot).len(); + + assert_eq!(cursor_row, DisplayRow(10)); + assert_eq!(snapshot.scroll_position().y, 9.); + assert_eq!(sticky_header_count, 1); + + // A scroll alone is insufficient: the full cursor row must be unobscured. + assert!( + cursor_row.as_f64() >= snapshot.scroll_position().y + sticky_header_count as f64 + ); + assert!( + cursor_row.next_row().as_f64() + <= snapshot.scroll_position().y + visible_lines as f64 + ); + }); + } +} + +#[gpui::test] +async fn test_autoscroll_keeps_cursor_visible_below_sticky_headers(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + update_test_editor_settings(cx, &|settings| { + settings.vertical_scroll_margin = Some(0.0); + settings.scroll_beyond_last_line = Some(ScrollBeyondLastLine::OnePage); + settings.sticky_scroll = Some(settings::StickyScrollContent { + enabled: Some(true), + }); + }); + let mut cx = EditorTestContext::new(cx).await; + + cx.set_state(indoc! {" + impl Foo { fn bar() { + let x = 1; + fn baz() { + let y = 2; + } + } } + ˇ + "}); + + let mut previous_cursor_row = cx.update_editor(|editor, window, cx| { + editor + .buffer() + .read(cx) + .as_singleton() + .unwrap() + .update(cx, |buffer, cx| buffer.set_language(Some(rust_lang()), cx)); + let cursor_row = editor + .selections + .newest_display(&editor.display_snapshot(cx)) + .head() + .row(); + editor.set_scroll_top_row(cursor_row, window, cx); + cursor_row + }); + + for _ in 0..6 { + cx.update_editor(|editor, window, cx| editor.move_up(&MoveUp, window, cx)); + cx.run_until_parked(); + + cx.update_editor(|editor, window, cx| { + let snapshot = editor.snapshot(window, cx); + let scroll_top = snapshot.scroll_position().y; + let sticky_header_count = EditorElement::sticky_headers(editor, &snapshot).len(); + let cursor_row = editor + .selections + .newest_display(&snapshot.display_snapshot) + .head() + .row(); + assert_eq!( + cursor_row, + previous_cursor_row + .previous_row() + .max(DisplayRow(scroll_top as u32) + DisplayRow(sticky_header_count as u32)) + ); + previous_cursor_row = cursor_row; + }); + + // The `ScrollCursorTop` action shouldn't change the scroll position, as the cursor is + // already as high up as the sticky headers allow. + let scroll_top_before = + cx.update_editor(|editor, window, cx| editor.snapshot(window, cx).scroll_position().y); + cx.update_editor(|editor, window, cx| { + editor.scroll_cursor_top(&ScrollCursorTop, window, cx) + }); + cx.run_until_parked(); + let scroll_top_after = + cx.update_editor(|editor, window, cx| editor.snapshot(window, cx).scroll_position().y); + assert_eq!(scroll_top_before, scroll_top_after); + } +} + +#[gpui::test] +fn test_relative_line_numbers(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let buffer_1 = cx.new(|cx| Buffer::local("aaaaaaaaaa\nbbb\n", cx)); + let buffer_2 = cx.new(|cx| Buffer::local("cccccccccc\nddd\n", cx)); + let buffer_3 = cx.new(|cx| Buffer::local("eee\nffffffffff\n", cx)); + + let multibuffer = cx.new(|cx| { + let mut multibuffer = MultiBuffer::new(ReadWrite); + multibuffer.set_excerpts_for_path( + PathKey::sorted(0), + buffer_1.clone(), + [Point::new(0, 0)..Point::new(2, 0)], + 0, + cx, + ); + multibuffer.set_excerpts_for_path( + PathKey::sorted(1), + buffer_2.clone(), + [Point::new(0, 0)..Point::new(2, 0)], + 0, + cx, + ); + multibuffer.set_excerpts_for_path( + PathKey::sorted(2), + buffer_3.clone(), + [Point::new(0, 0)..Point::new(2, 0)], + 0, + cx, + ); + multibuffer + }); + + // wrapped contents of multibuffer: + // aaa + // aaa + // aaa + // a + // bbb + // + // ccc + // ccc + // ccc + // c + // ddd + // + // eee + // fff + // fff + // fff + // f + + let editor = cx.add_window(|window, cx| build_editor(multibuffer, window, cx)); + _ = editor.update(cx, |editor, window, cx| { + editor.set_wrap_width(Some(30.0.into()), cx); // every 3 characters + + // includes trailing newlines. + let expected_line_numbers = [2, 6, 7, 10, 14, 15, 18, 19, 23]; + let expected_wrapped_line_numbers = [ + 2, 3, 4, 5, 6, 7, 10, 11, 12, 13, 14, 15, 18, 19, 20, 21, 22, 23, + ]; + + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_ranges([ + Point::new(7, 0)..Point::new(7, 1), // second row of `ccc` + ]); + }); + + let snapshot = editor.snapshot(window, cx); + + // these are all 0-indexed + let base_display_row = DisplayRow(11); + let base_row = 3; + let wrapped_base_row = 7; + + // test not counting wrapped lines + let expected_relative_numbers = expected_line_numbers + .into_iter() + .enumerate() + .map(|(i, row)| (DisplayRow(row), i.abs_diff(base_row) as u32)) + .filter(|(_, relative_line_number)| *relative_line_number != 0) + .collect_vec(); + let actual_relative_numbers = snapshot + .calculate_relative_line_numbers( + &(DisplayRow(0)..DisplayRow(24)), + base_display_row, + false, + ) + .into_iter() + .sorted() + .collect_vec(); + assert_eq!(expected_relative_numbers, actual_relative_numbers); + // check `calculate_relative_line_numbers()` against `relative_line_delta()` for each line + for (display_row, relative_number) in expected_relative_numbers { + assert_eq!( + relative_number, + snapshot + .relative_line_delta(display_row, base_display_row, false) + .unsigned_abs() as u32, + ); + } + + // test counting wrapped lines + let expected_wrapped_relative_numbers = expected_wrapped_line_numbers + .into_iter() + .enumerate() + .map(|(i, row)| (DisplayRow(row), i.abs_diff(wrapped_base_row) as u32)) + .filter(|(row, _)| *row != base_display_row) + .collect_vec(); + let actual_relative_numbers = snapshot + .calculate_relative_line_numbers( + &(DisplayRow(0)..DisplayRow(24)), + base_display_row, + true, + ) + .into_iter() + .sorted() + .collect_vec(); + assert_eq!(expected_wrapped_relative_numbers, actual_relative_numbers); + // check `calculate_relative_line_numbers()` against `relative_wrapped_line_delta()` for each line + for (display_row, relative_number) in expected_wrapped_relative_numbers { + assert_eq!( + relative_number, + snapshot + .relative_line_delta(display_row, base_display_row, true) + .unsigned_abs() as u32, + ); + } + }); +} + +#[gpui::test] +async fn test_scroll_by_clicking_sticky_header(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + cx.update(|cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings.editor.sticky_scroll = Some(settings::StickyScrollContent { + enabled: Some(true), + }) + }); + }); + }); + let mut cx = EditorTestContext::new(cx).await; + + let line_height = cx.update_editor(|editor, window, cx| { + editor + .style(cx) + .text + .line_height_in_pixels(window.rem_size()) + }); + + let buffer = indoc! {" + ˇfn foo() { + let abc = 123; + } + struct Bar; + impl Bar { + fn new() -> Self { + Self + } + } + fn baz() { + } + "}; + cx.set_state(&buffer); + + cx.update_editor(|e, _, cx| { + e.buffer() + .read(cx) + .as_singleton() + .unwrap() + .update(cx, |buffer, cx| { + buffer.set_language(Some(rust_lang()), cx); + }) + }); + + let fn_foo = || empty_range(0, 0); + let impl_bar = || empty_range(4, 0); + let fn_new = || empty_range(5, 0); + + let mut scroll_and_click = |scroll_offset: ScrollOffset, click_offset: ScrollOffset| { + cx.update_editor(|e, window, cx| { + e.scroll( + gpui::Point { + x: 0., + y: scroll_offset, + }, + window, + cx, + ); + }); + cx.run_until_parked(); + cx.simulate_click( + gpui::Point { + x: px(0.), + y: click_offset as f32 * line_height, + }, + Modifiers::none(), + ); + cx.run_until_parked(); + cx.update_editor(|e, _, cx| (e.scroll_position(cx), display_ranges(e, cx))) + }; + assert_eq!( + scroll_and_click( + 4.5, // impl Bar is halfway off the screen + 0.0 // click top of screen + ), + // scrolled to impl Bar + (gpui::Point { x: 0., y: 4. }, vec![impl_bar()]) + ); + + assert_eq!( + scroll_and_click( + 4.5, // impl Bar is halfway off the screen + 0.25 // click middle of impl Bar + ), + // scrolled to impl Bar + (gpui::Point { x: 0., y: 4. }, vec![impl_bar()]) + ); + + assert_eq!( + scroll_and_click( + 4.5, // impl Bar is halfway off the screen + 1.5 // click below impl Bar (e.g. fn new()) + ), + // scrolled to fn new() - this is below the impl Bar header which has persisted + (gpui::Point { x: 0., y: 4. }, vec![fn_new()]) + ); + + assert_eq!( + scroll_and_click( + 5.5, // fn new is halfway underneath impl Bar + 0.75 // click on the overlap of impl Bar and fn new() + ), + (gpui::Point { x: 0., y: 4. }, vec![impl_bar()]) + ); + + assert_eq!( + scroll_and_click( + 5.5, // fn new is halfway underneath impl Bar + 1.25 // click on the visible part of fn new() + ), + (gpui::Point { x: 0., y: 4. }, vec![fn_new()]) + ); + + assert_eq!( + scroll_and_click( + 1.5, // fn foo is halfway off the screen + 0.0 // click top of screen + ), + (gpui::Point { x: 0., y: 0. }, vec![fn_foo()]) + ); + + assert_eq!( + scroll_and_click( + 1.5, // fn foo is halfway off the screen + 0.75 // click visible part of let abc... + ) + .0, + // no change in scroll + // we don't assert on the visible_range because if we clicked the gutter, our line is fully selected + (gpui::Point { x: 0., y: 1.5 }) + ); + + // Verify clicking at a specific x position within a sticky header places + // the cursor at the corresponding column. + let (text_origin_x, em_width) = cx.update_editor(|editor, _, _| { + let position_map = editor.last_position_map.as_ref().unwrap(); + ( + position_map.text_hitbox.bounds.origin.x, + position_map.em_layout_width, + ) + }); + + // Click on "impl Bar {" sticky header at column 5 (the 'B' in 'Bar'). + // The text "impl Bar {" starts at column 0, so column 5 = 'B'. + let click_x = text_origin_x + em_width * 5.5; + cx.update_editor(|e, window, cx| { + e.scroll(gpui::Point { x: 0., y: 4.5 }, window, cx); + }); + cx.run_until_parked(); + cx.simulate_click( + gpui::Point { + x: click_x, + y: 0.25 * line_height, + }, + Modifiers::none(), + ); + cx.run_until_parked(); + let (scroll_pos, selections) = + cx.update_editor(|e, _, cx| (e.scroll_position(cx), display_ranges(e, cx))); + assert_eq!(scroll_pos, gpui::Point { x: 0., y: 4. }); + assert_eq!(selections, vec![empty_range(4, 5)]); +} + +#[gpui::test] +async fn test_clicking_sticky_header_sets_character_select_mode(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + cx.update(|cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings.editor.sticky_scroll = Some(settings::StickyScrollContent { + enabled: Some(true), + }) + }); + }); + }); + let mut cx = EditorTestContext::new(cx).await; + + let line_height = cx.update_editor(|editor, window, cx| { + editor + .style(cx) + .text + .line_height_in_pixels(window.rem_size()) + }); + + let buffer = indoc! {" + fn foo() { + let abc = 123; + } + ˇstruct Bar; + "}; + cx.set_state(&buffer); + + cx.update_editor(|editor, _, cx| { + editor + .buffer() + .read(cx) + .as_singleton() + .unwrap() + .update(cx, |buffer, cx| { + buffer.set_language(Some(rust_lang()), cx); + }) + }); + + let text_origin_x = cx.update_editor(|editor, _, _| { + editor + .last_position_map + .as_ref() + .unwrap() + .text_hitbox + .bounds + .origin + .x + }); + + cx.update_editor(|editor, window, cx| { + // Double click on `struct` to select it + editor.begin_selection(DisplayPoint::new(DisplayRow(3), 1), false, 2, window, cx); + editor.end_selection(window, cx); + + // Scroll down one row to make `fn foo() {` a sticky header + editor.scroll(gpui::Point { x: 0., y: 1. }, window, cx); + }); + cx.run_until_parked(); + + // Click at the start of the `fn foo() {` sticky header + cx.simulate_click( + gpui::Point { + x: text_origin_x, + y: 0.5 * line_height, + }, + Modifiers::none(), + ); + cx.run_until_parked(); + + // Shift-click at the end of `fn foo() {` to select the whole row + cx.update_editor(|editor, window, cx| { + editor.extend_selection(DisplayPoint::new(DisplayRow(0), 10), 1, window, cx); + editor.end_selection(window, cx); + }); + cx.run_until_parked(); + + let selections = cx.update_editor(|editor, _, cx| display_ranges(editor, cx)); + assert_eq!( + selections, + vec![DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 10)] + ); +} + +#[gpui::test] +async fn test_next_prev_reference(cx: &mut TestAppContext) { + const CYCLE_POSITIONS: &[&'static str] = &[ + indoc! {" + fn foo() { + let ˇabc = 123; + let x = abc + 1; + let y = abc + 2; + let z = abc + 2; + } + "}, + indoc! {" + fn foo() { + let abc = 123; + let x = ˇabc + 1; + let y = abc + 2; + let z = abc + 2; + } + "}, + indoc! {" + fn foo() { + let abc = 123; + let x = abc + 1; + let y = ˇabc + 2; + let z = abc + 2; + } + "}, + indoc! {" + fn foo() { + let abc = 123; + let x = abc + 1; + let y = abc + 2; + let z = ˇabc + 2; + } + "}, + ]; + + init_test(cx, |_| {}); + + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + references_provider: Some(lsp::OneOf::Left(true)), + ..Default::default() + }, + cx, + ) + .await; + + // importantly, the cursor is in the middle + cx.set_state(indoc! {" + fn foo() { + let aˇbc = 123; + let x = abc + 1; + let y = abc + 2; + let z = abc + 2; + } + "}); + + let reference_ranges = [ + lsp::Position::new(1, 8), + lsp::Position::new(2, 12), + lsp::Position::new(3, 12), + lsp::Position::new(4, 12), + ] + .map(|start| lsp::Range::new(start, lsp::Position::new(start.line, start.character + 3))); + + cx.lsp + .set_request_handler::(move |params, _cx| async move { + Ok(Some( + reference_ranges + .map(|range| lsp::Location { + uri: params.text_document_position.text_document.uri.clone(), + range, + }) + .to_vec(), + )) + }); + + let _move = async |direction, count, cx: &mut EditorLspTestContext| { + cx.update_editor(|editor, window, cx| { + editor.go_to_reference_before_or_after_position(direction, count, window, cx) + }) + .unwrap() + .await + .unwrap() + }; + + _move(Direction::Next, 1, &mut cx).await; + cx.assert_editor_state(CYCLE_POSITIONS[1]); + + _move(Direction::Next, 1, &mut cx).await; + cx.assert_editor_state(CYCLE_POSITIONS[2]); + + _move(Direction::Next, 1, &mut cx).await; + cx.assert_editor_state(CYCLE_POSITIONS[3]); + + // loops back to the start + _move(Direction::Next, 1, &mut cx).await; + cx.assert_editor_state(CYCLE_POSITIONS[0]); + + // loops back to the end + _move(Direction::Prev, 1, &mut cx).await; + cx.assert_editor_state(CYCLE_POSITIONS[3]); + + _move(Direction::Prev, 1, &mut cx).await; + cx.assert_editor_state(CYCLE_POSITIONS[2]); + + _move(Direction::Prev, 1, &mut cx).await; + cx.assert_editor_state(CYCLE_POSITIONS[1]); + + _move(Direction::Prev, 1, &mut cx).await; + cx.assert_editor_state(CYCLE_POSITIONS[0]); + + _move(Direction::Next, 3, &mut cx).await; + cx.assert_editor_state(CYCLE_POSITIONS[3]); + + _move(Direction::Prev, 2, &mut cx).await; + cx.assert_editor_state(CYCLE_POSITIONS[1]); +} + +#[gpui::test] +async fn test_multibuffer_selections_with_folding(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let (editor, cx) = cx.add_window_view(|window, cx| { + let multi_buffer = MultiBuffer::build_multi( + [ + ("1\n2\n3\n", vec![Point::row_range(0..3)]), + ("1\n2\n3\n", vec![Point::row_range(0..3)]), + ], + cx, + ); + Editor::new(EditorMode::full(), multi_buffer, None, window, cx) + }); + + let mut cx = EditorTestContext::for_editor_in(editor.clone(), cx).await; + let buffer_ids = cx.multibuffer(|mb, cx| { + mb.snapshot(cx) + .excerpts() + .map(|excerpt| excerpt.context.start.buffer_id) + .collect::>() + }); + + cx.assert_excerpts_with_selections(indoc! {" + [EXCERPT] + ˇ1 + 2 + 3 + [EXCERPT] + 1 + 2 + 3 + "}); + + // Scenario 1: Unfolded buffers, position cursor on "2", select all matches, then insert + cx.update_editor(|editor, window, cx| { + editor.change_selections(None.into(), window, cx, |s| { + s.select_ranges([MultiBufferOffset(2)..MultiBufferOffset(3)]); + }); + }); + cx.assert_excerpts_with_selections(indoc! {" + [EXCERPT] + 1 + 2ˇ + 3 + [EXCERPT] + 1 + 2 + 3 + "}); + + cx.update_editor(|editor, window, cx| { + editor + .select_all_matches(&SelectAllMatches, window, cx) + .unwrap(); + }); + cx.assert_excerpts_with_selections(indoc! {" + [EXCERPT] + 1 + 2ˇ + 3 + [EXCERPT] + 1 + 2ˇ + 3 + "}); + + cx.update_editor(|editor, window, cx| { + editor.handle_input("X", window, cx); + }); + cx.assert_excerpts_with_selections(indoc! {" + [EXCERPT] + 1 + Xˇ + 3 + [EXCERPT] + 1 + Xˇ + 3 + "}); + + // Scenario 2: Select "2", then fold second buffer before insertion + cx.update_multibuffer(|mb, cx| { + for buffer_id in buffer_ids.iter() { + let buffer = mb.buffer(*buffer_id).unwrap(); + buffer.update(cx, |buffer, cx| { + buffer.edit([(0..buffer.len(), "1\n2\n3\n")], None, cx); + }); + } + }); + + // Select "2" and select all matches + cx.update_editor(|editor, window, cx| { + editor.change_selections(None.into(), window, cx, |s| { + s.select_ranges([MultiBufferOffset(2)..MultiBufferOffset(3)]); + }); + editor + .select_all_matches(&SelectAllMatches, window, cx) + .unwrap(); + }); + + // Fold second buffer - should remove selections from folded buffer + cx.update_editor(|editor, _, cx| { + editor.fold_buffer(buffer_ids[1], cx); + }); + cx.assert_excerpts_with_selections(indoc! {" + [EXCERPT] + 1 + 2ˇ + 3 + [EXCERPT] + [FOLDED] + "}); + + // Insert text - should only affect first buffer + cx.update_editor(|editor, window, cx| { + editor.handle_input("Y", window, cx); + }); + cx.update_editor(|editor, _, cx| { + editor.unfold_buffer(buffer_ids[1], cx); + }); + cx.assert_excerpts_with_selections(indoc! {" + [EXCERPT] + 1 + Yˇ + 3 + [EXCERPT] + 1 + 2 + 3 + "}); + + // Scenario 3: Select "2", then fold first buffer before insertion + cx.update_multibuffer(|mb, cx| { + for buffer_id in buffer_ids.iter() { + let buffer = mb.buffer(*buffer_id).unwrap(); + buffer.update(cx, |buffer, cx| { + buffer.edit([(0..buffer.len(), "1\n2\n3\n")], None, cx); + }); + } + }); + + // Select "2" and select all matches + cx.update_editor(|editor, window, cx| { + editor.change_selections(None.into(), window, cx, |s| { + s.select_ranges([MultiBufferOffset(2)..MultiBufferOffset(3)]); + }); + editor + .select_all_matches(&SelectAllMatches, window, cx) + .unwrap(); + }); + + // Fold first buffer - should remove selections from folded buffer + cx.update_editor(|editor, _, cx| { + editor.fold_buffer(buffer_ids[0], cx); + }); + cx.assert_excerpts_with_selections(indoc! {" + [EXCERPT] + [FOLDED] + [EXCERPT] + 1 + 2ˇ + 3 + "}); + + // Insert text - should only affect second buffer + cx.update_editor(|editor, window, cx| { + editor.handle_input("Z", window, cx); + }); + cx.update_editor(|editor, _, cx| { + editor.unfold_buffer(buffer_ids[0], cx); + }); + cx.assert_excerpts_with_selections(indoc! {" + [EXCERPT] + 1 + 2 + 3 + [EXCERPT] + 1 + Zˇ + 3 + "}); + + // Test correct folded header is selected upon fold + cx.update_editor(|editor, _, cx| { + editor.fold_buffer(buffer_ids[0], cx); + editor.fold_buffer(buffer_ids[1], cx); + }); + cx.assert_excerpts_with_selections(indoc! {" + [EXCERPT] + [FOLDED] + [EXCERPT] + ˇ[FOLDED] + "}); + + // Test selection inside folded buffer unfolds it on type + cx.update_editor(|editor, window, cx| { + editor.handle_input("W", window, cx); + }); + cx.update_editor(|editor, _, cx| { + editor.unfold_buffer(buffer_ids[0], cx); + }); + cx.assert_excerpts_with_selections(indoc! {" + [EXCERPT] + 1 + 2 + 3 + [EXCERPT] + Wˇ1 + Z + 3 + "}); +} + +#[gpui::test] +async fn test_multibuffer_scroll_cursor_top_margin(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let (editor, cx) = cx.add_window_view(|window, cx| { + let multi_buffer = MultiBuffer::build_multi( + [ + ("1\n2\n3\n", vec![Point::row_range(0..3)]), + ("1\n2\n3\n4\n5\n6\n7\n8\n9\n", vec![Point::row_range(0..9)]), + ], + cx, + ); + Editor::new(EditorMode::full(), multi_buffer, None, window, cx) + }); + + let mut cx = EditorTestContext::for_editor_in(editor.clone(), cx).await; + + cx.assert_excerpts_with_selections(indoc! {" + [EXCERPT] + ˇ1 + 2 + 3 + [EXCERPT] + 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9 + "}); + + cx.update_editor(|editor, window, cx| { + editor.change_selections(None.into(), window, cx, |s| { + s.select_ranges([MultiBufferOffset(19)..MultiBufferOffset(19)]); + }); + }); + + cx.assert_excerpts_with_selections(indoc! {" + [EXCERPT] + 1 + 2 + 3 + [EXCERPT] + 1 + 2 + 3 + 4 + 5 + 6 + ˇ7 + 8 + 9 + "}); + + cx.update_editor(|editor, _window, cx| { + editor.set_vertical_scroll_margin(0, cx); + }); + + cx.update_editor(|editor, window, cx| { + assert_eq!(editor.vertical_scroll_margin(), 0); + editor.scroll_cursor_top(&ScrollCursorTop, window, cx); + assert_eq!( + editor.snapshot(window, cx).scroll_position(), + gpui::Point::new(0., 12.0) + ); + }); + + cx.update_editor(|editor, _window, cx| { + editor.set_vertical_scroll_margin(3, cx); + }); + + cx.update_editor(|editor, window, cx| { + assert_eq!(editor.vertical_scroll_margin(), 3); + editor.scroll_cursor_top(&ScrollCursorTop, window, cx); + assert_eq!( + editor.snapshot(window, cx).scroll_position(), + gpui::Point::new(0., 9.0) + ); + }); +} + +#[gpui::test] +async fn test_find_references_single_case(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + references_provider: Some(lsp::OneOf::Left(true)), + ..lsp::ServerCapabilities::default() + }, + cx, + ) + .await; + + let before = indoc!( + r#" + fn main() { + let aˇbc = 123; + let xyz = abc; + } + "# + ); + let after = indoc!( + r#" + fn main() { + let abc = 123; + let xyz = ˇabc; + } + "# + ); + + cx.lsp + .set_request_handler::(async move |params, _| { + Ok(Some(vec![ + lsp::Location { + uri: params.text_document_position.text_document.uri.clone(), + range: lsp::Range::new(lsp::Position::new(1, 8), lsp::Position::new(1, 11)), + }, + lsp::Location { + uri: params.text_document_position.text_document.uri, + range: lsp::Range::new(lsp::Position::new(2, 14), lsp::Position::new(2, 17)), + }, + ])) + }); + + cx.set_state(before); + + let action = FindAllReferences { + always_open_multibuffer: false, + open_results_in: None, + }; + + let navigated = cx + .update_editor(|editor, window, cx| editor.find_all_references(&action, window, cx)) + .expect("should have spawned a task") + .await + .unwrap(); + + assert_eq!(navigated, Navigated::No); + + cx.run_until_parked(); + + cx.assert_editor_state(after); +} + +/// Maps each resolved [`Location`] to `(row, start_column, end_column)`. +fn location_row_columns( + cx: &mut EditorLspTestContext, + locations: &[project::Location], +) -> Vec<(u32, u32, u32)> { + let mut rows = cx.update_editor(|_editor, _window, cx| { + locations + .iter() + .map(|location| { + let snapshot = location.buffer.read(cx).snapshot(); + let start: usize = snapshot.summary_for_anchor(&location.range.start); + let end: usize = snapshot.summary_for_anchor(&location.range.end); + let start = snapshot.offset_to_point(start); + let end = snapshot.offset_to_point(end); + (start.row, start.column, end.column) + }) + .collect::>() + }); + rows.sort(); + rows +} + +#[gpui::test] +async fn test_definition_locations_of_kind_excludes_self_link(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + definition_provider: Some(lsp::OneOf::Left(true)), + ..lsp::ServerCapabilities::default() + }, + cx, + ) + .await; + + // Cursor sits inside the `abc` use on row 2 (columns 14..17). + cx.set_state(indoc!( + r#" + fn main() { + let abc = 123; + let xyz = aˇbc; + } + "# + )); + + // The server returns two targets: the location covering the cursor (which + // must be filtered out as a self-link) and the real definition on row 1. + cx.lsp + .set_request_handler::(async move |params, _| { + let uri = params.text_document_position_params.text_document.uri; + Ok(Some(lsp::GotoDefinitionResponse::Array(vec![ + lsp::Location { + uri: uri.clone(), + range: lsp::Range::new(lsp::Position::new(2, 14), lsp::Position::new(2, 17)), + }, + lsp::Location { + uri, + range: lsp::Range::new(lsp::Position::new(1, 8), lsp::Position::new(1, 11)), + }, + ]))) + }); + + let locations = cx + .update_editor(|editor, _window, cx| { + editor.definition_locations_of_kind(GotoDefinitionKind::Symbol, cx) + }) + .expect("definition query should spawn a task") + .await + .unwrap(); + + // Only the real definition remains; the target covering the cursor is dropped. + assert_eq!(location_row_columns(&mut cx, &locations), vec![(1, 8, 11)]); +} + +#[test] +fn test_open_results_in_action_argument_parsing() { + // A bare keybinding (no arguments) resolves to `None`, i.e. defer to the + // `lsp_results_location` setting. Keymap loading builds actions from `{}` + // when no arguments are given, so that is what we deserialize here. + assert_eq!( + serde_json::from_value::(json!({})) + .unwrap() + .open_results_in, + None, + ); + + // The `OpenResultsIn` variants must keep the snake_case spelling that + // keymaps and the `lsp_results_location` setting rely on; a rename here + // would silently break those keybindings. + assert_eq!( + serde_json::from_value::(json!({ "open_results_in": "picker" })) + .unwrap() + .open_results_in, + Some(OpenResultsIn::Picker), + ); + assert_eq!( + serde_json::from_value::(json!({ "open_results_in": "multi_buffer" })) + .unwrap() + .open_results_in, + Some(OpenResultsIn::MultiBuffer), + ); + + // The argument coexists with `FindAllReferences`'s existing field, which + // keeps its own default when only `open_results_in` is provided. + let references = + serde_json::from_value::(json!({ "open_results_in": "picker" })) + .unwrap(); + assert_eq!(references.open_results_in, Some(OpenResultsIn::Picker)); + assert!(references.always_open_multibuffer); +} + +#[gpui::test] +async fn test_newline_task_list_continuation(cx: &mut TestAppContext) { + init_test(cx, |settings| { + settings.defaults.tab_size = Some(2.try_into().unwrap()); + }); + + let markdown_language = languages::language("markdown", tree_sitter_md::LANGUAGE.into()); + let mut cx = EditorTestContext::new(cx).await; + cx.update_buffer(|buffer, cx| buffer.set_language(Some(markdown_language), cx)); + + // Case 1: Adding newline after (whitespace + prefix + any non-whitespace) adds marker + cx.set_state(indoc! {" + - [ ] taskˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + - [ ] task + - [ ] ˇ + "}); + + // Case 2: Works with checked task items too + cx.set_state(indoc! {" + - [x] completed taskˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + - [x] completed task + - [ ] ˇ + "}); + + // Case 2.1: Works with uppercase checked marker too + cx.set_state(indoc! {" + - [X] completed taskˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + - [X] completed task + - [ ] ˇ + "}); + + // Case 3: Cursor position doesn't matter - content after marker is what counts + cx.set_state(indoc! {" + - [ ] taˇsk + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + - [ ] ta + - [ ] ˇsk + "}); + + // Case 4: Adding newline after (whitespace + prefix + some whitespace) does NOT add marker + cx.set_state(indoc! {" + - [ ] ˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state( + indoc! {" + - [ ]$$ + ˇ + "} + .replace("$", " ") + .as_str(), + ); + + // Case 5: Adding newline with content adds marker preserving indentation + cx.set_state(indoc! {" + - [ ] task + - [ ] indentedˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + - [ ] task + - [ ] indented + - [ ] ˇ + "}); + + // Case 6: Adding newline with cursor right after prefix, unindents + cx.set_state(indoc! {" + - [ ] task + - [ ] sub task + - [ ] ˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + - [ ] task + - [ ] sub task + - [ ] ˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + + // Case 7: Adding newline with cursor right after prefix, removes marker + cx.assert_editor_state(indoc! {" + - [ ] task + - [ ] sub task + - [ ] ˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + - [ ] task + - [ ] sub task + ˇ + "}); + + // Case 8: Cursor before or inside prefix does not add marker + cx.set_state(indoc! {" + ˇ- [ ] task + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + + ˇ- [ ] task + "}); + + cx.set_state(indoc! {" + - [ˇ ] task + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + - [ + ˇ + ] task + "}); +} + +#[gpui::test] +async fn test_newline_unordered_list_continuation(cx: &mut TestAppContext) { + init_test(cx, |settings| { + settings.defaults.tab_size = Some(2.try_into().unwrap()); + }); + + let markdown_language = languages::language("markdown", tree_sitter_md::LANGUAGE.into()); + let mut cx = EditorTestContext::new(cx).await; + cx.update_buffer(|buffer, cx| buffer.set_language(Some(markdown_language), cx)); + + // Case 1: Adding newline after (whitespace + marker + any non-whitespace) adds marker + cx.set_state(indoc! {" + - itemˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + - item + - ˇ + "}); + + // Case 2: Works with different markers + cx.set_state(indoc! {" + * starred itemˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + * starred item + * ˇ + "}); + + cx.set_state(indoc! {" + + plus itemˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + + plus item + + ˇ + "}); + + // Case 3: Cursor position doesn't matter - content after marker is what counts + cx.set_state(indoc! {" + - itˇem + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + - it + - ˇem + "}); + + // Case 4: Adding newline after (whitespace + marker + some whitespace) does NOT add marker + cx.set_state(indoc! {" + - ˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state( + indoc! {" + - $ + ˇ + "} + .replace("$", " ") + .as_str(), + ); + + // Case 5: Adding newline with content adds marker preserving indentation + cx.set_state(indoc! {" + - item + - indentedˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + - item + - indented + - ˇ + "}); + + // Case 6: Adding newline with cursor right after marker, unindents + cx.set_state(indoc! {" + - item + - sub item + - ˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + - item + - sub item + - ˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + + // Case 7: Adding newline with cursor right after marker, removes marker + cx.assert_editor_state(indoc! {" + - item + - sub item + - ˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + - item + - sub item + ˇ + "}); + + // Case 8: Cursor before or inside prefix does not add marker + cx.set_state(indoc! {" + ˇ- item + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + + ˇ- item + "}); + + cx.set_state(indoc! {" + -ˇ item + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + - + ˇitem + "}); + + update_test_language_settings(&mut cx, &|settings| { + settings.defaults.tab_size = Some(4.try_into().unwrap()); + }); + + // Case 9: Empty list item unindent works when tab size is larger than list indentation + cx.set_state(indoc! {" + - item + - sub item + - ˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + - item + - sub item + - ˇ + "}); + + // Case 10: Empty list item unindent moves to the previous tab stop + cx.set_state( + indoc! {" + $$$$$$- ˇ + "} + .replace("$", " ") + .as_str(), + ); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state( + indoc! {" + $$$$- ˇ + "} + .replace("$", " ") + .as_str(), + ); +} + +#[gpui::test] +async fn test_newline_ordered_list_continuation(cx: &mut TestAppContext) { + init_test(cx, |settings| { + settings.defaults.tab_size = Some(2.try_into().unwrap()); + }); + + let markdown_language = languages::language("markdown", tree_sitter_md::LANGUAGE.into()); + let mut cx = EditorTestContext::new(cx).await; + cx.update_buffer(|buffer, cx| buffer.set_language(Some(markdown_language), cx)); + + // Case 1: Adding newline after (whitespace + marker + any non-whitespace) increments number + cx.set_state(indoc! {" + 1. first itemˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + 1. first item + 2. ˇ + "}); + + // Case 2: Works with larger numbers + cx.set_state(indoc! {" + 10. tenth itemˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + 10. tenth item + 11. ˇ + "}); + + // Case 3: Cursor position doesn't matter - content after marker is what counts + cx.set_state(indoc! {" + 1. itˇem + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + 1. it + 2. ˇem + "}); + + // Case 4: Adding newline after (whitespace + marker + some whitespace) does NOT add marker + cx.set_state(indoc! {" + 1. ˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state( + indoc! {" + 1. $ + ˇ + "} + .replace("$", " ") + .as_str(), + ); + + // Case 5: Adding newline with content adds marker preserving indentation + cx.set_state(indoc! {" + 1. item + 2. indentedˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + 1. item + 2. indented + 3. ˇ + "}); + + // Case 6: Adding newline with cursor right after marker, unindents + cx.set_state(indoc! {" + 1. item + 2. sub item + 3. ˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + 1. item + 2. sub item + 1. ˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + + // Case 7: Adding newline with cursor right after marker, removes marker + cx.assert_editor_state(indoc! {" + 1. item + 2. sub item + 1. ˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + 1. item + 2. sub item + ˇ + "}); + + // Case 8: Cursor before or inside prefix does not add marker + cx.set_state(indoc! {" + ˇ1. item + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + + ˇ1. item + "}); + + cx.set_state(indoc! {" + 1ˇ. item + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + 1 + ˇ. item + "}); +} + +#[gpui::test] +async fn test_newline_should_not_autoindent_ordered_list(cx: &mut TestAppContext) { + init_test(cx, |settings| { + settings.defaults.tab_size = Some(2.try_into().unwrap()); + }); + + let markdown_language = languages::language("markdown", tree_sitter_md::LANGUAGE.into()); + let mut cx = EditorTestContext::new(cx).await; + cx.update_buffer(|buffer, cx| buffer.set_language(Some(markdown_language), cx)); + + // Case 1: Adding newline after (whitespace + marker + any non-whitespace) increments number + cx.set_state(indoc! {" + 1. first item + 1. sub first item + 2. sub second item + 3. ˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + 1. first item + 1. sub first item + 2. sub second item + 1. ˇ + "}); +} + +#[gpui::test] +async fn test_tab_list_indent(cx: &mut TestAppContext) { + init_test(cx, |settings| { + settings.defaults.tab_size = Some(2.try_into().unwrap()); + }); + + let markdown_language = languages::language("markdown", tree_sitter_md::LANGUAGE.into()); + let mut cx = EditorTestContext::new(cx).await; + cx.update_buffer(|buffer, cx| buffer.set_language(Some(markdown_language), cx)); + + // Case 1: Unordered list - cursor after prefix, adds indent before prefix + cx.set_state(indoc! {" + - ˇitem + "}); + cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); + cx.wait_for_autoindent_applied().await; + let expected = indoc! {" + $$- ˇitem + "}; + cx.assert_editor_state(expected.replace("$", " ").as_str()); + + // Case 2: Task list - cursor after prefix + cx.set_state(indoc! {" + - [ ] ˇtask + "}); + cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); + cx.wait_for_autoindent_applied().await; + let expected = indoc! {" + $$- [ ] ˇtask + "}; + cx.assert_editor_state(expected.replace("$", " ").as_str()); + + // Case 3: Ordered list - cursor after prefix + cx.set_state(indoc! {" + 1. ˇfirst + "}); + cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); + cx.wait_for_autoindent_applied().await; + let expected = indoc! {" + $$1. ˇfirst + "}; + cx.assert_editor_state(expected.replace("$", " ").as_str()); + + // Case 4: With existing indentation - adds more indent + let initial = indoc! {" + $$- ˇitem + "}; + cx.set_state(initial.replace("$", " ").as_str()); + cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); + cx.wait_for_autoindent_applied().await; + let expected = indoc! {" + $$$$- ˇitem + "}; + cx.assert_editor_state(expected.replace("$", " ").as_str()); + + // Case 5: Empty list item + cx.set_state(indoc! {" + - ˇ + "}); + cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); + cx.wait_for_autoindent_applied().await; + let expected = indoc! {" + $$- ˇ + "}; + cx.assert_editor_state(expected.replace("$", " ").as_str()); + + // Case 6: Cursor at end of line with content + cx.set_state(indoc! {" + - itemˇ + "}); + cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); + cx.wait_for_autoindent_applied().await; + let expected = indoc! {" + $$- itemˇ + "}; + cx.assert_editor_state(expected.replace("$", " ").as_str()); + + // Case 7: Cursor at start of list item, indents it + cx.set_state(indoc! {" + - item + ˇ - sub item + "}); + cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); + cx.wait_for_autoindent_applied().await; + let expected = indoc! {" + - item + ˇ - sub item + "}; + cx.assert_editor_state(expected); + + // Case 8: Cursor at start of list item, moves the cursor when "indent_list_on_tab" is false + cx.update_editor(|_, _, cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings.project.all_languages.defaults.indent_list_on_tab = Some(false); + }); + }); + }); + cx.set_state(indoc! {" + - item + ˇ - sub item + "}); + cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); + cx.wait_for_autoindent_applied().await; + let expected = indoc! {" + - item + ˇ- sub item + "}; + cx.assert_editor_state(expected); +} + +#[gpui::test] +async fn test_local_worktree_trust(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + cx.update(|cx| project::trusted_worktrees::init(HashMap::default(), cx)); + + cx.update(|cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings.project.all_languages.defaults.inlay_hints = + Some(InlayHintSettingsContent { + enabled: Some(true), + ..InlayHintSettingsContent::default() + }); + }); + }); + }); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/project"), + json!({ + ".zed": { + "settings.json": r#"{"languages":{"Rust":{"language_servers":["override-rust-analyzer"]}}}"# + }, + "main.rs": "fn main() {}" + }), + ) + .await; + + let lsp_inlay_hint_request_count = Arc::new(AtomicUsize::new(0)); + let server_name = "override-rust-analyzer"; + let project = Project::test_with_worktree_trust(fs, [path!("/project").as_ref()], cx).await; + + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(rust_lang()); + + let capabilities = lsp::ServerCapabilities { + inlay_hint_provider: Some(lsp::OneOf::Left(true)), + ..lsp::ServerCapabilities::default() + }; + let mut fake_language_servers = language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + name: server_name, + capabilities, + initializer: Some(Box::new({ + let lsp_inlay_hint_request_count = lsp_inlay_hint_request_count.clone(); + move |fake_server| { + let lsp_inlay_hint_request_count = lsp_inlay_hint_request_count.clone(); + fake_server.set_request_handler::( + move |_params, _| { + lsp_inlay_hint_request_count.fetch_add(1, atomic::Ordering::Release); + async move { + Ok(Some(vec![lsp::InlayHint { + position: lsp::Position::new(0, 0), + label: lsp::InlayHintLabel::String("hint".to_string()), + kind: None, + text_edits: None, + tooltip: None, + padding_left: None, + padding_right: None, + data: None, + }])) + } + }, + ); + } + })), + ..FakeLspAdapter::default() + }, + ); + + cx.run_until_parked(); + + let worktree_id = project.read_with(cx, |project, cx| { + project + .worktrees(cx) + .next() + .map(|wt| wt.read(cx).id()) + .expect("should have a worktree") + }); + let worktree_store = project.read_with(cx, |project, _| project.worktree_store()); + + let trusted_worktrees = + cx.update(|cx| TrustedWorktrees::try_get_global(cx).expect("trust global should exist")); + + let can_trust = trusted_worktrees.update(cx, |store, cx| { + store.can_trust(&worktree_store, worktree_id, cx) + }); + assert!(!can_trust, "worktree should be restricted initially"); + + let buffer_before_approval = project + .update(cx, |project, cx| { + project.open_buffer((worktree_id, rel_path("main.rs")), cx) + }) + .await + .unwrap(); + + let (editor, cx) = cx.add_window_view(|window, cx| { + Editor::new( + EditorMode::full(), + cx.new(|cx| MultiBuffer::singleton(buffer_before_approval.clone(), cx)), + Some(project.clone()), + window, + cx, + ) + }); + cx.run_until_parked(); + let fake_language_server = fake_language_servers.next(); + + cx.read(|cx| { + assert_eq!( + language::language_settings::LanguageSettings::for_buffer( + buffer_before_approval.read(cx), + cx + ) + .language_servers, + [language::language_settings::ConfiguredLanguageServer::new( + "..." + )], + "local .zed/settings.json must not apply before trust approval" + ) + }); + + editor.update_in(cx, |editor, window, cx| { + editor.handle_input("1", window, cx); + }); + cx.run_until_parked(); + cx.executor() + .advance_clock(std::time::Duration::from_secs(1)); + assert_eq!( + lsp_inlay_hint_request_count.load(atomic::Ordering::Acquire), + 0, + "inlay hints must not be queried before trust approval" + ); + + trusted_worktrees.update(cx, |store, cx| { + store.trust( + &worktree_store, + std::collections::HashSet::from_iter([PathTrust::Worktree(worktree_id)]), + cx, + ); + }); + cx.run_until_parked(); + + cx.read(|cx| { + assert_eq!( + language::language_settings::LanguageSettings::for_buffer( + buffer_before_approval.read(cx), + cx + ) + .language_servers, + [language::language_settings::ConfiguredLanguageServer::new( + "override-rust-analyzer" + )], + "local .zed/settings.json should apply after trust approval" + ) + }); + let _fake_language_server = fake_language_server.await.unwrap(); + editor.update_in(cx, |editor, window, cx| { + editor.handle_input("1", window, cx); + }); + cx.run_until_parked(); + cx.executor() + .advance_clock(std::time::Duration::from_secs(1)); + assert!( + lsp_inlay_hint_request_count.load(atomic::Ordering::Acquire) > 0, + "inlay hints should be queried after trust approval" + ); + + let can_trust_after = trusted_worktrees.update(cx, |store, cx| { + store.can_trust(&worktree_store, worktree_id, cx) + }); + assert!(can_trust_after, "worktree should be trusted after trust()"); +} + +#[gpui::test] +fn test_editor_rendering_when_positioned_above_viewport(cx: &mut TestAppContext) { + // This test reproduces a bug where drawing an editor at a position above the viewport + // (simulating what happens when an AutoHeight editor inside a List is scrolled past) + // causes an infinite loop in blocks_in_range. + // + // The issue: when the editor's bounds.origin.y is very negative (above the viewport), + // the content mask intersection produces visible_bounds with origin at the viewport top. + // This makes clipped_top_in_lines very large, causing start_row to exceed max_row. + // When blocks_in_range is called with start_row > max_row, the cursor seeks to the end + // but the while loop after seek never terminates because cursor.next() is a no-op at end. + init_test(cx, |_| {}); + + let window = cx.add_window(|_, _| gpui::Empty); + let mut cx = VisualTestContext::from_window(*window, cx); + + let buffer = cx.update(|_, cx| MultiBuffer::build_simple("a\nb\nc\nd\ne\nf\ng\nh\ni\nj\n", cx)); + let editor = cx.new_window_entity(|window, cx| build_editor(buffer, window, cx)); + + // Simulate a small viewport (500x500 pixels at origin 0,0) + cx.simulate_resize(gpui::size(px(500.), px(500.))); + + // Draw the editor at a very negative Y position, simulating an editor that's been + // scrolled way above the visible viewport (like in a List that has scrolled past it). + // The editor is 3000px tall but positioned at y=-10000, so it's entirely above the viewport. + // This should NOT hang - it should just render nothing. + cx.draw( + gpui::point(px(0.), px(-10000.)), + gpui::size(px(500.), px(3000.)), + |_, _| editor.clone().into_any_element(), + ); + + // If we get here without hanging, the test passes +} + +#[gpui::test] +async fn test_diff_review_indicator_created_on_gutter_hover(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/root"), json!({ "file.txt": "hello\nworld\n" })) + .await; + + let project = Project::test(fs, [path!("/root").as_ref()], cx).await; + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(*window, cx); + + let editor = workspace + .update_in(cx, |workspace, window, cx| { + workspace.open_abs_path( + PathBuf::from(path!("/root/file.txt")), + OpenOptions::default(), + window, + cx, + ) + }) + .await + .unwrap() + .downcast::() + .unwrap(); + + // Enable diff review button mode + editor.update(cx, |editor, cx| { + editor.set_show_diff_review_button(true, cx); + }); + + // Initially, no indicator should be present + editor.update(cx, |editor, _cx| { + assert!( + editor.gutter_diff_review_indicator.0.is_none(), + "Indicator should be None initially" + ); + }); +} + +#[gpui::test] +async fn test_diff_review_button_hidden_when_ai_disabled(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + // Register DisableAiSettings and set disable_ai to true + cx.update(|cx| { + project::DisableAiSettings::register(cx); + project::DisableAiSettings::override_global( + project::DisableAiSettings { disable_ai: true }, + cx, + ); + }); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/root"), json!({ "file.txt": "hello\nworld\n" })) + .await; + + let project = Project::test(fs, [path!("/root").as_ref()], cx).await; + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(*window, cx); + + let editor = workspace + .update_in(cx, |workspace, window, cx| { + workspace.open_abs_path( + PathBuf::from(path!("/root/file.txt")), + OpenOptions::default(), + window, + cx, + ) + }) + .await + .unwrap() + .downcast::() + .unwrap(); + + // Enable diff review button mode + editor.update(cx, |editor, cx| { + editor.set_show_diff_review_button(true, cx); + }); + + // Verify AI is disabled + cx.read(|cx| { + assert!( + project::DisableAiSettings::get_global(cx).disable_ai, + "AI should be disabled" + ); + }); + + // The indicator should not be created when AI is disabled + // (The mouse_moved handler checks DisableAiSettings before creating the indicator) + editor.update(cx, |editor, _cx| { + assert!( + editor.gutter_diff_review_indicator.0.is_none(), + "Indicator should be None when AI is disabled" + ); + }); +} + +#[gpui::test] +async fn test_diff_review_button_shown_when_ai_enabled(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + // Register DisableAiSettings and set disable_ai to false + cx.update(|cx| { + project::DisableAiSettings::register(cx); + project::DisableAiSettings::override_global( + project::DisableAiSettings { disable_ai: false }, + cx, + ); + }); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/root"), json!({ "file.txt": "hello\nworld\n" })) + .await; + + let project = Project::test(fs, [path!("/root").as_ref()], cx).await; + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(*window, cx); + + let editor = workspace + .update_in(cx, |workspace, window, cx| { + workspace.open_abs_path( + PathBuf::from(path!("/root/file.txt")), + OpenOptions::default(), + window, + cx, + ) + }) + .await + .unwrap() + .downcast::() + .unwrap(); + + // Enable diff review button mode + editor.update(cx, |editor, cx| { + editor.set_show_diff_review_button(true, cx); + }); + + // Verify AI is enabled + cx.read(|cx| { + assert!( + !project::DisableAiSettings::get_global(cx).disable_ai, + "AI should be enabled" + ); + }); + + // The show_diff_review_button flag should be true + editor.update(cx, |editor, _cx| { + assert!( + editor.show_diff_review_button(), + "show_diff_review_button should be true" + ); + }); +} + +/// Helper function to create a DiffHunkKey for testing. +/// Uses Anchor::Min as a placeholder anchor since these tests don't need +/// real buffer positioning. +fn test_hunk_key(file_path: &str) -> DiffHunkKey { + DiffHunkKey { + file_path: if file_path.is_empty() { + Arc::from(util::rel_path::RelPath::empty()) + } else { + Arc::from(util::rel_path::RelPath::from_unix_str(file_path).unwrap()) + }, + hunk_start_anchor: Anchor::Min, + } +} + +/// Helper function to create a DiffHunkKey with a specific anchor for testing. +fn test_hunk_key_with_anchor(file_path: &str, anchor: Anchor) -> DiffHunkKey { + DiffHunkKey { + file_path: if file_path.is_empty() { + Arc::from(util::rel_path::RelPath::empty()) + } else { + Arc::from(util::rel_path::RelPath::from_unix_str(file_path).unwrap()) + }, + hunk_start_anchor: anchor, + } +} + +/// Helper function to add a review comment with default anchors for testing. +fn add_test_comment( + editor: &mut Editor, + key: DiffHunkKey, + comment: &str, + cx: &mut Context, +) -> usize { + editor.add_review_comment(key, comment.to_string(), Anchor::Min..Anchor::Max, cx) +} + +#[gpui::test] +fn test_review_comment_add_to_hunk(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let editor = cx.add_window(|window, cx| Editor::single_line(window, cx)); + + _ = editor.update(cx, |editor: &mut Editor, _window, cx| { + let key = test_hunk_key(""); + + let id = add_test_comment(editor, key.clone(), "Test comment", cx); + + let snapshot = editor.buffer().read(cx).snapshot(cx); + assert_eq!(editor.total_review_comment_count(), 1); + assert_eq!(editor.hunk_comment_count(&key, &snapshot), 1); + + let comments = editor.comments_for_hunk(&key, &snapshot); + assert_eq!(comments.len(), 1); + assert_eq!(comments[0].comment, "Test comment"); + assert_eq!(comments[0].id, id); + }); +} + +#[gpui::test] +fn test_review_comments_are_per_hunk(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let editor = cx.add_window(|window, cx| Editor::single_line(window, cx)); + + _ = editor.update(cx, |editor: &mut Editor, _window, cx| { + let snapshot = editor.buffer().read(cx).snapshot(cx); + let anchor1 = snapshot.anchor_before(Point::new(0, 0)); + let anchor2 = snapshot.anchor_before(Point::new(0, 0)); + let key1 = test_hunk_key_with_anchor("file1.rs", anchor1); + let key2 = test_hunk_key_with_anchor("file2.rs", anchor2); + + add_test_comment(editor, key1.clone(), "Comment for file1", cx); + add_test_comment(editor, key2.clone(), "Comment for file2", cx); + + let snapshot = editor.buffer().read(cx).snapshot(cx); + assert_eq!(editor.total_review_comment_count(), 2); + assert_eq!(editor.hunk_comment_count(&key1, &snapshot), 1); + assert_eq!(editor.hunk_comment_count(&key2, &snapshot), 1); + + assert_eq!( + editor.comments_for_hunk(&key1, &snapshot)[0].comment, + "Comment for file1" + ); + assert_eq!( + editor.comments_for_hunk(&key2, &snapshot)[0].comment, + "Comment for file2" + ); + }); +} + +#[gpui::test] +fn test_review_comment_remove(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let editor = cx.add_window(|window, cx| Editor::single_line(window, cx)); + + _ = editor.update(cx, |editor: &mut Editor, _window, cx| { + let key = test_hunk_key(""); + + let id = add_test_comment(editor, key, "To be removed", cx); + + assert_eq!(editor.total_review_comment_count(), 1); + + let removed = editor.remove_review_comment(id, cx); + assert!(removed); + assert_eq!(editor.total_review_comment_count(), 0); + + // Try to remove again + let removed_again = editor.remove_review_comment(id, cx); + assert!(!removed_again); + }); +} + +#[gpui::test] +fn test_review_comment_update(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let editor = cx.add_window(|window, cx| Editor::single_line(window, cx)); + + _ = editor.update(cx, |editor: &mut Editor, _window, cx| { + let key = test_hunk_key(""); + + let id = add_test_comment(editor, key.clone(), "Original text", cx); + + let updated = editor.update_review_comment(id, "Updated text".to_string(), cx); + assert!(updated); + + let snapshot = editor.buffer().read(cx).snapshot(cx); + let comments = editor.comments_for_hunk(&key, &snapshot); + assert_eq!(comments[0].comment, "Updated text"); + assert!(!comments[0].is_editing); // Should clear editing flag + }); +} + +#[gpui::test] +fn test_review_comment_take_all(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let editor = cx.add_window(|window, cx| Editor::single_line(window, cx)); + + _ = editor.update(cx, |editor: &mut Editor, _window, cx| { + let snapshot = editor.buffer().read(cx).snapshot(cx); + let anchor1 = snapshot.anchor_before(Point::new(0, 0)); + let anchor2 = snapshot.anchor_before(Point::new(0, 0)); + let key1 = test_hunk_key_with_anchor("file1.rs", anchor1); + let key2 = test_hunk_key_with_anchor("file2.rs", anchor2); + + let id1 = add_test_comment(editor, key1.clone(), "Comment 1", cx); + let id2 = add_test_comment(editor, key1.clone(), "Comment 2", cx); + let id3 = add_test_comment(editor, key2.clone(), "Comment 3", cx); + + // IDs should be sequential starting from 0 + assert_eq!(id1, 0); + assert_eq!(id2, 1); + assert_eq!(id3, 2); + + assert_eq!(editor.total_review_comment_count(), 3); + + let taken = editor.take_all_review_comments(cx); + + // Should have 2 entries (one per hunk) + assert_eq!(taken.len(), 2); + + // Total comments should be 3 + let total: usize = taken + .iter() + .map(|(_, comments): &(DiffHunkKey, Vec)| comments.len()) + .sum(); + assert_eq!(total, 3); + + // Storage should be empty + assert_eq!(editor.total_review_comment_count(), 0); + + // After taking all comments, ID counter should reset + // New comments should get IDs starting from 0 again + let new_id1 = add_test_comment(editor, key1, "New Comment 1", cx); + let new_id2 = add_test_comment(editor, key2, "New Comment 2", cx); + + assert_eq!(new_id1, 0, "ID counter should reset after take_all"); + assert_eq!(new_id2, 1, "IDs should be sequential after reset"); + }); +} + +#[gpui::test] +fn test_diff_review_overlay_show_and_dismiss(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let editor = cx.add_window(|window, cx| Editor::single_line(window, cx)); + + // Show overlay + editor + .update(cx, |editor, window, cx| { + editor.show_diff_review_overlay(DisplayRow(0)..DisplayRow(0), window, cx); + }) + .unwrap(); + + // Verify overlay is shown + editor + .update(cx, |editor, _window, cx| { + assert!(!editor.diff_review_overlays.is_empty()); + assert_eq!(editor.diff_review_line_range(cx), Some((0, 0))); + assert!(editor.diff_review_prompt_editor().is_some()); + }) + .unwrap(); + + // Dismiss overlay + editor + .update(cx, |editor, _window, cx| { + editor.dismiss_all_diff_review_overlays(cx); + }) + .unwrap(); + + // Verify overlay is dismissed + editor + .update(cx, |editor, _window, cx| { + assert!(editor.diff_review_overlays.is_empty()); + assert_eq!(editor.diff_review_line_range(cx), None); + assert!(editor.diff_review_prompt_editor().is_none()); + }) + .unwrap(); +} + +#[gpui::test] +fn test_diff_review_overlay_dismiss_via_cancel(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let editor = cx.add_window(|window, cx| Editor::single_line(window, cx)); + + // Show overlay + editor + .update(cx, |editor, window, cx| { + editor.show_diff_review_overlay(DisplayRow(0)..DisplayRow(0), window, cx); + }) + .unwrap(); + + // Verify overlay is shown + editor + .update(cx, |editor, _window, _cx| { + assert!(!editor.diff_review_overlays.is_empty()); + }) + .unwrap(); + + // Dismiss via dismiss_menus_and_popups (which is called by cancel action) + editor + .update(cx, |editor, window, cx| { + editor.dismiss_menus_and_popups(true, window, cx); + }) + .unwrap(); + + // Verify overlay is dismissed + editor + .update(cx, |editor, _window, _cx| { + assert!(editor.diff_review_overlays.is_empty()); + }) + .unwrap(); +} + +#[gpui::test] +fn test_diff_review_empty_comment_not_submitted(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let editor = cx.add_window(|window, cx| Editor::single_line(window, cx)); + + // Show overlay + editor + .update(cx, |editor, window, cx| { + editor.show_diff_review_overlay(DisplayRow(0)..DisplayRow(0), window, cx); + }) + .unwrap(); + + // Try to submit without typing anything (empty comment) + editor + .update(cx, |editor, window, cx| { + editor.submit_diff_review_comment(window, cx); + }) + .unwrap(); + + // Verify no comment was added + editor + .update(cx, |editor, _window, _cx| { + assert_eq!(editor.total_review_comment_count(), 0); + }) + .unwrap(); + + // Try to submit with whitespace-only comment + editor + .update(cx, |editor, window, cx| { + if let Some(prompt_editor) = editor.diff_review_prompt_editor().cloned() { + prompt_editor.update(cx, |pe, cx| { + pe.insert(" \n\t ", window, cx); + }); + } + editor.submit_diff_review_comment(window, cx); + }) + .unwrap(); + + // Verify still no comment was added + editor + .update(cx, |editor, _window, _cx| { + assert_eq!(editor.total_review_comment_count(), 0); + }) + .unwrap(); +} + +#[gpui::test] +fn test_diff_review_inline_edit_flow(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let editor = cx.add_window(|window, cx| Editor::single_line(window, cx)); + + // Add a comment directly + let comment_id = editor + .update(cx, |editor, _window, cx| { + let key = test_hunk_key(""); + add_test_comment(editor, key, "Original comment", cx) + }) + .unwrap(); + + // Set comment to editing mode + editor + .update(cx, |editor, _window, cx| { + editor.set_comment_editing(comment_id, true, cx); + }) + .unwrap(); + + // Verify editing flag is set + editor + .update(cx, |editor, _window, cx| { + let key = test_hunk_key(""); + let snapshot = editor.buffer().read(cx).snapshot(cx); + let comments = editor.comments_for_hunk(&key, &snapshot); + assert_eq!(comments.len(), 1); + assert!(comments[0].is_editing); + }) + .unwrap(); + + // Update the comment + editor + .update(cx, |editor, _window, cx| { + let updated = + editor.update_review_comment(comment_id, "Updated comment".to_string(), cx); + assert!(updated); + }) + .unwrap(); + + // Verify comment was updated and editing flag is cleared + editor + .update(cx, |editor, _window, cx| { + let key = test_hunk_key(""); + let snapshot = editor.buffer().read(cx).snapshot(cx); + let comments = editor.comments_for_hunk(&key, &snapshot); + assert_eq!(comments[0].comment, "Updated comment"); + assert!(!comments[0].is_editing); + }) + .unwrap(); +} + +#[gpui::test] +fn test_orphaned_comments_are_cleaned_up(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + // Create an editor with some text + let editor = cx.add_window(|window, cx| { + let buffer = cx.new(|cx| Buffer::local("line 1\nline 2\nline 3\n", cx)); + let multi_buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + Editor::new(EditorMode::full(), multi_buffer, None, window, cx) + }); + + // Add a comment with an anchor on line 2 + editor + .update(cx, |editor, _window, cx| { + let snapshot = editor.buffer().read(cx).snapshot(cx); + let anchor = snapshot.anchor_after(Point::new(1, 0)); // Line 2 + let key = DiffHunkKey { + file_path: Arc::from(util::rel_path::RelPath::empty()), + hunk_start_anchor: anchor, + }; + editor.add_review_comment(key, "Comment on line 2".to_string(), anchor..anchor, cx); + assert_eq!(editor.total_review_comment_count(), 1); + }) + .unwrap(); + + // Delete all content (this should orphan the comment's anchor) + editor + .update(cx, |editor, window, cx| { + editor.select_all(&SelectAll, window, cx); + editor.insert("completely new content", window, cx); + }) + .unwrap(); + + // Trigger cleanup + editor + .update(cx, |editor, _window, cx| { + editor.cleanup_orphaned_review_comments(cx); + // Comment should be removed because its anchor is invalid + assert_eq!(editor.total_review_comment_count(), 0); + }) + .unwrap(); +} + +#[gpui::test] +fn test_orphaned_comments_cleanup_called_on_buffer_edit(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + // Create an editor with some text + let editor = cx.add_window(|window, cx| { + let buffer = cx.new(|cx| Buffer::local("line 1\nline 2\nline 3\n", cx)); + let multi_buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + Editor::new(EditorMode::full(), multi_buffer, None, window, cx) + }); + + // Add a comment with an anchor on line 2 + editor + .update(cx, |editor, _window, cx| { + let snapshot = editor.buffer().read(cx).snapshot(cx); + let anchor = snapshot.anchor_after(Point::new(1, 0)); // Line 2 + let key = DiffHunkKey { + file_path: Arc::from(util::rel_path::RelPath::empty()), + hunk_start_anchor: anchor, + }; + editor.add_review_comment(key, "Comment on line 2".to_string(), anchor..anchor, cx); + assert_eq!(editor.total_review_comment_count(), 1); + }) + .unwrap(); + + // Edit the buffer - this should trigger cleanup via on_buffer_event + // Delete all content which orphans the anchor + editor + .update(cx, |editor, window, cx| { + editor.select_all(&SelectAll, window, cx); + editor.insert("completely new content", window, cx); + // The cleanup is called automatically in on_buffer_event when Edited fires + }) + .unwrap(); + + // Verify cleanup happened automatically (not manually triggered) + editor + .update(cx, |editor, _window, _cx| { + // Comment should be removed because its anchor became invalid + // and cleanup was called automatically on buffer edit + assert_eq!(editor.total_review_comment_count(), 0); + }) + .unwrap(); +} + +#[gpui::test] +fn test_comments_stored_for_multiple_hunks(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + // This test verifies that comments can be stored for multiple different hunks + // and that hunk_comment_count correctly identifies comments per hunk. + let editor = cx.add_window(|window, cx| Editor::single_line(window, cx)); + + _ = editor.update(cx, |editor, _window, cx| { + let snapshot = editor.buffer().read(cx).snapshot(cx); + + // Create two different hunk keys (simulating two different files) + let anchor = snapshot.anchor_before(Point::new(0, 0)); + let key1 = DiffHunkKey { + file_path: Arc::from(util::rel_path::RelPath::from_unix_str("file1.rs").unwrap()), + hunk_start_anchor: anchor, + }; + let key2 = DiffHunkKey { + file_path: Arc::from(util::rel_path::RelPath::from_unix_str("file2.rs").unwrap()), + hunk_start_anchor: anchor, + }; + + // Add comments to first hunk + editor.add_review_comment( + key1.clone(), + "Comment 1 for file1".to_string(), + anchor..anchor, + cx, + ); + editor.add_review_comment( + key1.clone(), + "Comment 2 for file1".to_string(), + anchor..anchor, + cx, + ); + + // Add comment to second hunk + editor.add_review_comment( + key2.clone(), + "Comment for file2".to_string(), + anchor..anchor, + cx, + ); + + // Verify total count + assert_eq!(editor.total_review_comment_count(), 3); + + // Verify per-hunk counts + let snapshot = editor.buffer().read(cx).snapshot(cx); + assert_eq!( + editor.hunk_comment_count(&key1, &snapshot), + 2, + "file1 should have 2 comments" + ); + assert_eq!( + editor.hunk_comment_count(&key2, &snapshot), + 1, + "file2 should have 1 comment" + ); + + // Verify comments_for_hunk returns correct comments + let file1_comments = editor.comments_for_hunk(&key1, &snapshot); + assert_eq!(file1_comments.len(), 2); + assert_eq!(file1_comments[0].comment, "Comment 1 for file1"); + assert_eq!(file1_comments[1].comment, "Comment 2 for file1"); + + let file2_comments = editor.comments_for_hunk(&key2, &snapshot); + assert_eq!(file2_comments.len(), 1); + assert_eq!(file2_comments[0].comment, "Comment for file2"); + }); +} + +#[gpui::test] +fn test_same_hunk_detected_by_matching_keys(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + // This test verifies that hunk_keys_match correctly identifies when two + // DiffHunkKeys refer to the same hunk (same file path and anchor point). + let editor = cx.add_window(|window, cx| Editor::single_line(window, cx)); + + _ = editor.update(cx, |editor, _window, cx| { + let snapshot = editor.buffer().read(cx).snapshot(cx); + let anchor = snapshot.anchor_before(Point::new(0, 0)); + + // Create two keys with the same file path and anchor + let key1 = DiffHunkKey { + file_path: Arc::from(util::rel_path::RelPath::from_unix_str("file.rs").unwrap()), + hunk_start_anchor: anchor, + }; + let key2 = DiffHunkKey { + file_path: Arc::from(util::rel_path::RelPath::from_unix_str("file.rs").unwrap()), + hunk_start_anchor: anchor, + }; + + // Add comment to first key + editor.add_review_comment(key1, "Test comment".to_string(), anchor..anchor, cx); + + // Verify second key (same hunk) finds the comment + let snapshot = editor.buffer().read(cx).snapshot(cx); + assert_eq!( + editor.hunk_comment_count(&key2, &snapshot), + 1, + "Same hunk should find the comment" + ); + + // Create a key with different file path + let different_file_key = DiffHunkKey { + file_path: Arc::from(util::rel_path::RelPath::from_unix_str("other.rs").unwrap()), + hunk_start_anchor: anchor, + }; + + // Different file should not find the comment + assert_eq!( + editor.hunk_comment_count(&different_file_key, &snapshot), + 0, + "Different file should not find the comment" + ); + }); +} + +#[gpui::test] +fn test_overlay_comments_expanded_state(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + // This test verifies that set_diff_review_comments_expanded correctly + // updates the expanded state of overlays. + let editor = cx.add_window(|window, cx| Editor::single_line(window, cx)); + + // Show overlay + editor + .update(cx, |editor, window, cx| { + editor.show_diff_review_overlay(DisplayRow(0)..DisplayRow(0), window, cx); + }) + .unwrap(); + + // Verify initially expanded (default) + editor + .update(cx, |editor, _window, _cx| { + assert!( + editor.diff_review_overlays[0].comments_expanded, + "Should be expanded by default" + ); + }) + .unwrap(); + + // Set to collapsed using the public method + editor + .update(cx, |editor, _window, cx| { + editor.set_diff_review_comments_expanded(false, cx); + }) + .unwrap(); + + // Verify collapsed + editor + .update(cx, |editor, _window, _cx| { + assert!( + !editor.diff_review_overlays[0].comments_expanded, + "Should be collapsed after setting to false" + ); + }) + .unwrap(); + + // Set back to expanded + editor + .update(cx, |editor, _window, cx| { + editor.set_diff_review_comments_expanded(true, cx); + }) + .unwrap(); + + // Verify expanded again + editor + .update(cx, |editor, _window, _cx| { + assert!( + editor.diff_review_overlays[0].comments_expanded, + "Should be expanded after setting to true" + ); + }) + .unwrap(); +} + +#[gpui::test] +fn test_diff_review_multiline_selection(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + // Create an editor with multiple lines of text + let editor = cx.add_window(|window, cx| { + let buffer = cx.new(|cx| Buffer::local("line 1\nline 2\nline 3\nline 4\nline 5\n", cx)); + let multi_buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + Editor::new(EditorMode::full(), multi_buffer, None, window, cx) + }); + + // Test showing overlay with a multi-line selection (lines 1-3, which are rows 0-2) + editor + .update(cx, |editor, window, cx| { + editor.show_diff_review_overlay(DisplayRow(0)..DisplayRow(2), window, cx); + }) + .unwrap(); + + // Verify line range + editor + .update(cx, |editor, _window, cx| { + assert!(!editor.diff_review_overlays.is_empty()); + assert_eq!(editor.diff_review_line_range(cx), Some((0, 2))); + }) + .unwrap(); + + // Dismiss and test with reversed range (end < start) + editor + .update(cx, |editor, _window, cx| { + editor.dismiss_all_diff_review_overlays(cx); + }) + .unwrap(); + + // Show overlay with reversed range - should normalize it + editor + .update(cx, |editor, window, cx| { + editor.show_diff_review_overlay(DisplayRow(3)..DisplayRow(1), window, cx); + }) + .unwrap(); + + // Verify range is normalized (start <= end) + editor + .update(cx, |editor, _window, cx| { + assert_eq!(editor.diff_review_line_range(cx), Some((1, 3))); + }) + .unwrap(); +} + +#[gpui::test] +fn test_diff_review_drag_state(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let editor = cx.add_window(|window, cx| { + let buffer = cx.new(|cx| Buffer::local("line 1\nline 2\nline 3\n", cx)); + let multi_buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + Editor::new(EditorMode::full(), multi_buffer, None, window, cx) + }); + + // Initially no drag state + editor + .update(cx, |editor, _window, _cx| { + assert!(editor.diff_review_drag_state.is_none()); + }) + .unwrap(); + + // Start drag at row 1 + editor + .update(cx, |editor, window, cx| { + editor.start_diff_review_drag(DisplayRow(1), window, cx); + }) + .unwrap(); + + // Verify drag state is set + editor + .update(cx, |editor, window, cx| { + assert!(editor.diff_review_drag_state.is_some()); + let snapshot = editor.snapshot(window, cx); + let range = editor + .diff_review_drag_state + .as_ref() + .unwrap() + .row_range(&snapshot.display_snapshot); + assert_eq!(*range.start(), DisplayRow(1)); + assert_eq!(*range.end(), DisplayRow(1)); + }) + .unwrap(); + + // Update drag to row 3 + editor + .update(cx, |editor, window, cx| { + editor.update_diff_review_drag(DisplayRow(3), window, cx); + }) + .unwrap(); + + // Verify drag state is updated + editor + .update(cx, |editor, window, cx| { + assert!(editor.diff_review_drag_state.is_some()); + let snapshot = editor.snapshot(window, cx); + let range = editor + .diff_review_drag_state + .as_ref() + .unwrap() + .row_range(&snapshot.display_snapshot); + assert_eq!(*range.start(), DisplayRow(1)); + assert_eq!(*range.end(), DisplayRow(3)); + }) + .unwrap(); + + // End drag - should show overlay + editor + .update(cx, |editor, window, cx| { + editor.end_diff_review_drag(window, cx); + }) + .unwrap(); + + // Verify drag state is cleared and overlay is shown + editor + .update(cx, |editor, _window, cx| { + assert!(editor.diff_review_drag_state.is_none()); + assert!(!editor.diff_review_overlays.is_empty()); + assert_eq!(editor.diff_review_line_range(cx), Some((1, 3))); + }) + .unwrap(); +} + +#[gpui::test] +fn test_diff_review_drag_cancel(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let editor = cx.add_window(|window, cx| Editor::single_line(window, cx)); + + // Start drag + editor + .update(cx, |editor, window, cx| { + editor.start_diff_review_drag(DisplayRow(0), window, cx); + }) + .unwrap(); + + // Verify drag state is set + editor + .update(cx, |editor, _window, _cx| { + assert!(editor.diff_review_drag_state.is_some()); + }) + .unwrap(); + + // Cancel drag + editor + .update(cx, |editor, _window, cx| { + editor.cancel_diff_review_drag(cx); + }) + .unwrap(); + + // Verify drag state is cleared and no overlay was created + editor + .update(cx, |editor, _window, _cx| { + assert!(editor.diff_review_drag_state.is_none()); + assert!(editor.diff_review_overlays.is_empty()); + }) + .unwrap(); +} + +#[gpui::test] +fn test_calculate_overlay_height(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + // This test verifies that calculate_overlay_height returns correct heights + // based on comment count and expanded state. + let editor = cx.add_window(|window, cx| Editor::single_line(window, cx)); + + _ = editor.update(cx, |editor, _window, cx| { + let snapshot = editor.buffer().read(cx).snapshot(cx); + let anchor = snapshot.anchor_before(Point::new(0, 0)); + let key = DiffHunkKey { + file_path: Arc::from(util::rel_path::RelPath::empty()), + hunk_start_anchor: anchor, + }; + + // No comments: base height of 2 + let height_no_comments = editor.calculate_overlay_height(&key, true, &snapshot); + assert_eq!( + height_no_comments, 2, + "Base height should be 2 with no comments" + ); + + // Add one comment + editor.add_review_comment(key.clone(), "Comment 1".to_string(), anchor..anchor, cx); + + let snapshot = editor.buffer().read(cx).snapshot(cx); + + // With comments expanded: base (2) + header (1) + 2 per comment + let height_expanded = editor.calculate_overlay_height(&key, true, &snapshot); + assert_eq!( + height_expanded, + 2 + 1 + 2, // base + header + 1 comment * 2 + "Height with 1 comment expanded" + ); + + // With comments collapsed: base (2) + header (1) + let height_collapsed = editor.calculate_overlay_height(&key, false, &snapshot); + assert_eq!( + height_collapsed, + 2 + 1, // base + header only + "Height with comments collapsed" + ); + + // Add more comments + editor.add_review_comment(key.clone(), "Comment 2".to_string(), anchor..anchor, cx); + editor.add_review_comment(key.clone(), "Comment 3".to_string(), anchor..anchor, cx); + + let snapshot = editor.buffer().read(cx).snapshot(cx); + + // With 3 comments expanded + let height_3_expanded = editor.calculate_overlay_height(&key, true, &snapshot); + assert_eq!( + height_3_expanded, + 2 + 1 + (3 * 2), // base + header + 3 comments * 2 + "Height with 3 comments expanded" + ); + + // Collapsed height stays the same regardless of comment count + let height_3_collapsed = editor.calculate_overlay_height(&key, false, &snapshot); + assert_eq!( + height_3_collapsed, + 2 + 1, // base + header only + "Height with 3 comments collapsed should be same as 1 comment collapsed" + ); + }); +} + +#[gpui::test] +async fn test_move_to_start_end_of_larger_syntax_node_single_cursor(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let language = Arc::new(Language::new( + LanguageConfig::default(), + Some(tree_sitter_rust::LANGUAGE.into()), + )); + + let text = r#" + fn main() { + let x = foo(1, 2); + } + "# + .unindent(); + + let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(language, cx)); + let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + let (editor, cx) = cx.add_window_view(|window, cx| build_editor(buffer, window, cx)); + + editor + .condition::(cx, |editor, cx| !editor.buffer.read(cx).is_parsing(cx)) + .await; + + // Test case 1: Move to end of syntax nodes + editor.update_in(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(1), 16)..DisplayPoint::new(DisplayRow(1), 16) + ]); + }); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections( + editor, + indoc! {r#" + fn main() { + let x = foo(ˇ1, 2); + } + "#}, + cx, + ); + }); + editor.update_in(cx, |editor, window, cx| { + editor.move_to_end_of_larger_syntax_node(&MoveToEndOfLargerSyntaxNode, window, cx); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections( + editor, + indoc! {r#" + fn main() { + let x = foo(1ˇ, 2); + } + "#}, + cx, + ); + }); + editor.update_in(cx, |editor, window, cx| { + editor.move_to_end_of_larger_syntax_node(&MoveToEndOfLargerSyntaxNode, window, cx); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections( + editor, + indoc! {r#" + fn main() { + let x = foo(1, 2)ˇ; + } + "#}, + cx, + ); + }); + editor.update_in(cx, |editor, window, cx| { + editor.move_to_end_of_larger_syntax_node(&MoveToEndOfLargerSyntaxNode, window, cx); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections( + editor, + indoc! {r#" + fn main() { + let x = foo(1, 2);ˇ + } + "#}, + cx, + ); + }); + editor.update_in(cx, |editor, window, cx| { + editor.move_to_end_of_larger_syntax_node(&MoveToEndOfLargerSyntaxNode, window, cx); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections( + editor, + indoc! {r#" + fn main() { + let x = foo(1, 2); + }ˇ + "#}, + cx, + ); + }); + + // Test case 2: Move to start of syntax nodes + editor.update_in(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(1), 20)..DisplayPoint::new(DisplayRow(1), 20) + ]); + }); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections( + editor, + indoc! {r#" + fn main() { + let x = foo(1, 2ˇ); + } + "#}, + cx, + ); + }); + editor.update_in(cx, |editor, window, cx| { + editor.move_to_start_of_larger_syntax_node(&MoveToStartOfLargerSyntaxNode, window, cx); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections( + editor, + indoc! {r#" + fn main() { + let x = fooˇ(1, 2); + } + "#}, + cx, + ); + }); + editor.update_in(cx, |editor, window, cx| { + editor.move_to_start_of_larger_syntax_node(&MoveToStartOfLargerSyntaxNode, window, cx); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections( + editor, + indoc! {r#" + fn main() { + let x = ˇfoo(1, 2); + } + "#}, + cx, + ); + }); + editor.update_in(cx, |editor, window, cx| { + editor.move_to_start_of_larger_syntax_node(&MoveToStartOfLargerSyntaxNode, window, cx); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections( + editor, + indoc! {r#" + fn main() { + ˇlet x = foo(1, 2); + } + "#}, + cx, + ); + }); + editor.update_in(cx, |editor, window, cx| { + editor.move_to_start_of_larger_syntax_node(&MoveToStartOfLargerSyntaxNode, window, cx); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections( + editor, + indoc! {r#" + fn main() ˇ{ + let x = foo(1, 2); + } + "#}, + cx, + ); + }); + editor.update_in(cx, |editor, window, cx| { + editor.move_to_start_of_larger_syntax_node(&MoveToStartOfLargerSyntaxNode, window, cx); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections( + editor, + indoc! {r#" + ˇfn main() { + let x = foo(1, 2); + } + "#}, + cx, + ); + }); +} + +#[gpui::test] +async fn test_move_to_start_end_of_larger_syntax_node_two_cursors(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let language = Arc::new(Language::new( + LanguageConfig::default(), + Some(tree_sitter_rust::LANGUAGE.into()), + )); + + let text = r#" + fn main() { + let x = foo(1, 2); + let y = bar(3, 4); + } + "# + .unindent(); + + let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(language, cx)); + let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + let (editor, cx) = cx.add_window_view(|window, cx| build_editor(buffer, window, cx)); + + editor + .condition::(cx, |editor, cx| !editor.buffer.read(cx).is_parsing(cx)) + .await; + + // Test case 1: Move to end of syntax nodes with two cursors + editor.update_in(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(1), 20)..DisplayPoint::new(DisplayRow(1), 20), + DisplayPoint::new(DisplayRow(2), 20)..DisplayPoint::new(DisplayRow(2), 20), + ]); + }); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections( + editor, + indoc! {r#" + fn main() { + let x = foo(1, 2ˇ); + let y = bar(3, 4ˇ); + } + "#}, + cx, + ); + }); + editor.update_in(cx, |editor, window, cx| { + editor.move_to_end_of_larger_syntax_node(&MoveToEndOfLargerSyntaxNode, window, cx); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections( + editor, + indoc! {r#" + fn main() { + let x = foo(1, 2)ˇ; + let y = bar(3, 4)ˇ; + } + "#}, + cx, + ); + }); + editor.update_in(cx, |editor, window, cx| { + editor.move_to_end_of_larger_syntax_node(&MoveToEndOfLargerSyntaxNode, window, cx); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections( + editor, + indoc! {r#" + fn main() { + let x = foo(1, 2);ˇ + let y = bar(3, 4);ˇ + } + "#}, + cx, + ); + }); + + // Test case 2: Move to start of syntax nodes with two cursors + editor.update_in(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(1), 19)..DisplayPoint::new(DisplayRow(1), 19), + DisplayPoint::new(DisplayRow(2), 19)..DisplayPoint::new(DisplayRow(2), 19), + ]); + }); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections( + editor, + indoc! {r#" + fn main() { + let x = foo(1, ˇ2); + let y = bar(3, ˇ4); + } + "#}, + cx, + ); + }); + editor.update_in(cx, |editor, window, cx| { + editor.move_to_start_of_larger_syntax_node(&MoveToStartOfLargerSyntaxNode, window, cx); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections( + editor, + indoc! {r#" + fn main() { + let x = fooˇ(1, 2); + let y = barˇ(3, 4); + } + "#}, + cx, + ); + }); + editor.update_in(cx, |editor, window, cx| { + editor.move_to_start_of_larger_syntax_node(&MoveToStartOfLargerSyntaxNode, window, cx); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections( + editor, + indoc! {r#" + fn main() { + let x = ˇfoo(1, 2); + let y = ˇbar(3, 4); + } + "#}, + cx, + ); + }); + editor.update_in(cx, |editor, window, cx| { + editor.move_to_start_of_larger_syntax_node(&MoveToStartOfLargerSyntaxNode, window, cx); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections( + editor, + indoc! {r#" + fn main() { + ˇlet x = foo(1, 2); + ˇlet y = bar(3, 4); + } + "#}, + cx, + ); + }); +} + +#[gpui::test] +async fn test_move_to_start_end_of_larger_syntax_node_with_selections_and_strings( + cx: &mut TestAppContext, +) { + init_test(cx, |_| {}); + + let language = Arc::new(Language::new( + LanguageConfig::default(), + Some(tree_sitter_rust::LANGUAGE.into()), + )); + + let text = r#" + fn main() { + let x = foo(1, 2); + let msg = "hello world"; + } + "# + .unindent(); + + let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(language, cx)); + let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + let (editor, cx) = cx.add_window_view(|window, cx| build_editor(buffer, window, cx)); + + editor + .condition::(cx, |editor, cx| !editor.buffer.read(cx).is_parsing(cx)) + .await; + + // Test case 1: With existing selection, move_to_end keeps selection + editor.update_in(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(1), 12)..DisplayPoint::new(DisplayRow(1), 21) + ]); + }); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections( + editor, + indoc! {r#" + fn main() { + let x = «foo(1, 2)ˇ»; + let msg = "hello world"; + } + "#}, + cx, + ); + }); + editor.update_in(cx, |editor, window, cx| { + editor.move_to_end_of_larger_syntax_node(&MoveToEndOfLargerSyntaxNode, window, cx); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections( + editor, + indoc! {r#" + fn main() { + let x = «foo(1, 2)ˇ»; + let msg = "hello world"; + } + "#}, + cx, + ); + }); + + // Test case 2: Move to end within a string + editor.update_in(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(2), 15)..DisplayPoint::new(DisplayRow(2), 15) + ]); + }); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections( + editor, + indoc! {r#" + fn main() { + let x = foo(1, 2); + let msg = "ˇhello world"; + } + "#}, + cx, + ); + }); + editor.update_in(cx, |editor, window, cx| { + editor.move_to_end_of_larger_syntax_node(&MoveToEndOfLargerSyntaxNode, window, cx); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections( + editor, + indoc! {r#" + fn main() { + let x = foo(1, 2); + let msg = "hello worldˇ"; + } + "#}, + cx, + ); + }); + + // Test case 3: Move to start within a string + editor.update_in(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(2), 21)..DisplayPoint::new(DisplayRow(2), 21) + ]); + }); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections( + editor, + indoc! {r#" + fn main() { + let x = foo(1, 2); + let msg = "hello ˇworld"; + } + "#}, + cx, + ); + }); + editor.update_in(cx, |editor, window, cx| { + editor.move_to_start_of_larger_syntax_node(&MoveToStartOfLargerSyntaxNode, window, cx); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections( + editor, + indoc! {r#" + fn main() { + let x = foo(1, 2); + let msg = "ˇhello world"; + } + "#}, + cx, + ); + }); +} + +#[gpui::test] +async fn test_select_to_start_end_of_larger_syntax_node(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let language = Arc::new(Language::new( + LanguageConfig::default(), + Some(tree_sitter_rust::LANGUAGE.into()), + )); + + // Test Group 1.1: Cursor in String - First Jump (Select to End) + let text = r#"let msg = "foo bar baz";"#.unindent(); + + let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(language.clone(), cx)); + let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + let (editor, cx) = cx.add_window_view(|window, cx| build_editor(buffer, window, cx)); + + editor + .condition::(cx, |editor, cx| !editor.buffer.read(cx).is_parsing(cx)) + .await; + + editor.update_in(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(0), 14)..DisplayPoint::new(DisplayRow(0), 14) + ]); + }); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections(editor, indoc! {r#"let msg = "fooˇ bar baz";"#}, cx); + }); + editor.update_in(cx, |editor, window, cx| { + editor.select_to_end_of_larger_syntax_node(&SelectToEndOfLargerSyntaxNode, window, cx); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections(editor, indoc! {r#"let msg = "foo« bar bazˇ»";"#}, cx); + }); + + // Test Group 1.2: Cursor in String - Second Jump (Select to End) + editor.update_in(cx, |editor, window, cx| { + editor.select_to_end_of_larger_syntax_node(&SelectToEndOfLargerSyntaxNode, window, cx); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections(editor, indoc! {r#"let msg = "foo« bar baz"ˇ»;"#}, cx); + }); + + // Test Group 1.3: Cursor in String - Third Jump (Select to End) + editor.update_in(cx, |editor, window, cx| { + editor.select_to_end_of_larger_syntax_node(&SelectToEndOfLargerSyntaxNode, window, cx); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections(editor, indoc! {r#"let msg = "foo« bar baz";ˇ»"#}, cx); + }); + + // Test Group 1.4: Cursor in String - First Jump (Select to Start) + editor.update_in(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(0), 18)..DisplayPoint::new(DisplayRow(0), 18) + ]); + }); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections(editor, indoc! {r#"let msg = "foo barˇ baz";"#}, cx); + }); + editor.update_in(cx, |editor, window, cx| { + editor.select_to_start_of_larger_syntax_node(&SelectToStartOfLargerSyntaxNode, window, cx); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections(editor, indoc! {r#"let msg = "«ˇfoo bar» baz";"#}, cx); + }); + + // Test Group 1.5: Cursor in String - Second Jump (Select to Start) + editor.update_in(cx, |editor, window, cx| { + editor.select_to_start_of_larger_syntax_node(&SelectToStartOfLargerSyntaxNode, window, cx); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections(editor, indoc! {r#"let msg = «ˇ"foo bar» baz";"#}, cx); + }); + + // Test Group 1.6: Cursor in String - Third Jump (Select to Start) + editor.update_in(cx, |editor, window, cx| { + editor.select_to_start_of_larger_syntax_node(&SelectToStartOfLargerSyntaxNode, window, cx); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections(editor, indoc! {r#"«ˇlet msg = "foo bar» baz";"#}, cx); + }); + + // Test Group 2.1: Let Statement Progression (Select to End) + let text = r#" +fn main() { + let x = "hello"; +} +"# + .unindent(); + + let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(language.clone(), cx)); + let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + let (editor, cx) = cx.add_window_view(|window, cx| build_editor(buffer, window, cx)); + + editor + .condition::(cx, |editor, cx| !editor.buffer.read(cx).is_parsing(cx)) + .await; + + editor.update_in(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(1), 9)..DisplayPoint::new(DisplayRow(1), 9) + ]); + }); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections( + editor, + indoc! {r#" + fn main() { + let xˇ = "hello"; + } + "#}, + cx, + ); + }); + editor.update_in(cx, |editor, window, cx| { + editor.select_to_end_of_larger_syntax_node(&SelectToEndOfLargerSyntaxNode, window, cx); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections( + editor, + indoc! {r##" + fn main() { + let x« = "hello";ˇ» + } + "##}, + cx, + ); + }); + editor.update_in(cx, |editor, window, cx| { + editor.select_to_end_of_larger_syntax_node(&SelectToEndOfLargerSyntaxNode, window, cx); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections( + editor, + indoc! {r#" + fn main() { + let x« = "hello"; + }ˇ» + "#}, + cx, + ); + }); + + // Test Group 2.2a: From Inside String Content Node To String Content Boundary + let text = r#"let x = "hello";"#.unindent(); + + let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(language.clone(), cx)); + let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + let (editor, cx) = cx.add_window_view(|window, cx| build_editor(buffer, window, cx)); + + editor + .condition::(cx, |editor, cx| !editor.buffer.read(cx).is_parsing(cx)) + .await; + + editor.update_in(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(0), 12)..DisplayPoint::new(DisplayRow(0), 12) + ]); + }); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections(editor, indoc! {r#"let x = "helˇlo";"#}, cx); + }); + editor.update_in(cx, |editor, window, cx| { + editor.select_to_start_of_larger_syntax_node(&SelectToStartOfLargerSyntaxNode, window, cx); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections(editor, indoc! {r#"let x = "«ˇhel»lo";"#}, cx); + }); + + // Test Group 2.2b: From Edge of String Content Node To String Literal Boundary + editor.update_in(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(0), 9)..DisplayPoint::new(DisplayRow(0), 9) + ]); + }); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections(editor, indoc! {r#"let x = "ˇhello";"#}, cx); + }); + editor.update_in(cx, |editor, window, cx| { + editor.select_to_start_of_larger_syntax_node(&SelectToStartOfLargerSyntaxNode, window, cx); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections(editor, indoc! {r#"let x = «ˇ"»hello";"#}, cx); + }); + + // Test Group 3.1: Create Selection from Cursor (Select to End) + let text = r#"let x = "hello world";"#.unindent(); + + let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(language.clone(), cx)); + let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + let (editor, cx) = cx.add_window_view(|window, cx| build_editor(buffer, window, cx)); + + editor + .condition::(cx, |editor, cx| !editor.buffer.read(cx).is_parsing(cx)) + .await; + + editor.update_in(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(0), 14)..DisplayPoint::new(DisplayRow(0), 14) + ]); + }); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections(editor, indoc! {r#"let x = "helloˇ world";"#}, cx); + }); + editor.update_in(cx, |editor, window, cx| { + editor.select_to_end_of_larger_syntax_node(&SelectToEndOfLargerSyntaxNode, window, cx); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections(editor, indoc! {r#"let x = "hello« worldˇ»";"#}, cx); + }); + + // Test Group 3.2: Extend Existing Selection (Select to End) + editor.update_in(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(0), 11)..DisplayPoint::new(DisplayRow(0), 17) + ]); + }); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections(editor, indoc! {r#"let x = "he«llo woˇ»rld";"#}, cx); + }); + editor.update_in(cx, |editor, window, cx| { + editor.select_to_end_of_larger_syntax_node(&SelectToEndOfLargerSyntaxNode, window, cx); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections(editor, indoc! {r#"let x = "he«llo worldˇ»";"#}, cx); + }); + + // Test Group 4.1: Multiple Cursors - All Expand to Different Syntax Nodes + let text = r#"let x = "hello"; let y = 42;"#.unindent(); + + let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(language.clone(), cx)); + let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + let (editor, cx) = cx.add_window_view(|window, cx| build_editor(buffer, window, cx)); + + editor + .condition::(cx, |editor, cx| !editor.buffer.read(cx).is_parsing(cx)) + .await; + + editor.update_in(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + // Cursor inside string content + DisplayPoint::new(DisplayRow(0), 12)..DisplayPoint::new(DisplayRow(0), 12), + // Cursor at let statement semicolon + DisplayPoint::new(DisplayRow(0), 18)..DisplayPoint::new(DisplayRow(0), 18), + // Cursor inside integer literal + DisplayPoint::new(DisplayRow(0), 26)..DisplayPoint::new(DisplayRow(0), 26), + ]); + }); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections(editor, indoc! {r#"let x = "helˇlo"; lˇet y = 4ˇ2;"#}, cx); + }); + editor.update_in(cx, |editor, window, cx| { + editor.select_to_end_of_larger_syntax_node(&SelectToEndOfLargerSyntaxNode, window, cx); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections(editor, indoc! {r#"let x = "hel«loˇ»"; l«et y = 42;ˇ»"#}, cx); + }); + + // Test Group 4.2: Multiple Cursors on Separate Lines + let text = r#" +let x = "hello"; +let y = 42; +"# + .unindent(); + + let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(language.clone(), cx)); + let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + let (editor, cx) = cx.add_window_view(|window, cx| build_editor(buffer, window, cx)); + + editor + .condition::(cx, |editor, cx| !editor.buffer.read(cx).is_parsing(cx)) + .await; + + editor.update_in(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(0), 12)..DisplayPoint::new(DisplayRow(0), 12), + DisplayPoint::new(DisplayRow(1), 9)..DisplayPoint::new(DisplayRow(1), 9), + ]); + }); + }); + + editor.update(cx, |editor, cx| { + assert_text_with_selections( + editor, + indoc! {r#" + let x = "helˇlo"; + let y = 4ˇ2; + "#}, + cx, + ); + }); + editor.update_in(cx, |editor, window, cx| { + editor.select_to_end_of_larger_syntax_node(&SelectToEndOfLargerSyntaxNode, window, cx); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections( + editor, + indoc! {r#" + let x = "hel«loˇ»"; + let y = 4«2ˇ»; + "#}, + cx, + ); + }); + + // Test Group 5.1: Nested Function Calls + let text = r#"let result = foo(bar("arg"));"#.unindent(); + + let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(language.clone(), cx)); + let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + let (editor, cx) = cx.add_window_view(|window, cx| build_editor(buffer, window, cx)); + + editor + .condition::(cx, |editor, cx| !editor.buffer.read(cx).is_parsing(cx)) + .await; + + editor.update_in(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(0), 22)..DisplayPoint::new(DisplayRow(0), 22) + ]); + }); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections(editor, indoc! {r#"let result = foo(bar("ˇarg"));"#}, cx); + }); + editor.update_in(cx, |editor, window, cx| { + editor.select_to_end_of_larger_syntax_node(&SelectToEndOfLargerSyntaxNode, window, cx); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections(editor, indoc! {r#"let result = foo(bar("«argˇ»"));"#}, cx); + }); + editor.update_in(cx, |editor, window, cx| { + editor.select_to_end_of_larger_syntax_node(&SelectToEndOfLargerSyntaxNode, window, cx); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections(editor, indoc! {r#"let result = foo(bar("«arg"ˇ»));"#}, cx); + }); + editor.update_in(cx, |editor, window, cx| { + editor.select_to_end_of_larger_syntax_node(&SelectToEndOfLargerSyntaxNode, window, cx); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections(editor, indoc! {r#"let result = foo(bar("«arg")ˇ»);"#}, cx); + }); + + // Test Group 6.1: Block Comments + let text = r#"let x = /* multi + line + comment */;"# + .unindent(); + + let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(language.clone(), cx)); + let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + let (editor, cx) = cx.add_window_view(|window, cx| build_editor(buffer, window, cx)); + + editor + .condition::(cx, |editor, cx| !editor.buffer.read(cx).is_parsing(cx)) + .await; + + editor.update_in(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(0), 16)..DisplayPoint::new(DisplayRow(0), 16) + ]); + }); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections( + editor, + indoc! {r#" +let x = /* multiˇ +line +comment */;"#}, + cx, + ); + }); + editor.update_in(cx, |editor, window, cx| { + editor.select_to_end_of_larger_syntax_node(&SelectToEndOfLargerSyntaxNode, window, cx); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections( + editor, + indoc! {r#" +let x = /* multi« +line +comment */ˇ»;"#}, + cx, + ); + }); + + // Test Group 6.2: Array/Vector Literals + let text = r#"let arr = [1, 2, 3];"#.unindent(); + + let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(language.clone(), cx)); + let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + let (editor, cx) = cx.add_window_view(|window, cx| build_editor(buffer, window, cx)); + + editor + .condition::(cx, |editor, cx| !editor.buffer.read(cx).is_parsing(cx)) + .await; + + editor.update_in(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(0), 11)..DisplayPoint::new(DisplayRow(0), 11) + ]); + }); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections(editor, indoc! {r#"let arr = [ˇ1, 2, 3];"#}, cx); + }); + editor.update_in(cx, |editor, window, cx| { + editor.select_to_end_of_larger_syntax_node(&SelectToEndOfLargerSyntaxNode, window, cx); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections(editor, indoc! {r#"let arr = [«1ˇ», 2, 3];"#}, cx); + }); + editor.update_in(cx, |editor, window, cx| { + editor.select_to_end_of_larger_syntax_node(&SelectToEndOfLargerSyntaxNode, window, cx); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections(editor, indoc! {r#"let arr = [«1, 2, 3]ˇ»;"#}, cx); + }); +} + +#[gpui::test] +async fn test_restore_and_next(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + + let diff_base = r#" + one + two + three + four + five + "# + .unindent(); + + cx.set_state( + &r#" + ONE + two + ˇTHREE + four + FIVE + "# + .unindent(), + ); + cx.set_head_text(&diff_base); + + cx.update_editor(|editor, window, cx| { + editor.set_expand_all_diff_hunks(cx); + editor.restore_and_next(&Default::default(), window, cx); + }); + cx.run_until_parked(); + + cx.assert_state_with_diff( + r#" + - one + + ONE + two + three + four + - ˇfive + + FIVE + "# + .unindent(), + ); + + cx.update_editor(|editor, window, cx| { + editor.restore_and_next(&Default::default(), window, cx); + }); + cx.run_until_parked(); + + cx.assert_state_with_diff( + r#" + - one + + ONE + two + three + four + ˇfive + "# + .unindent(), + ); +} + +#[gpui::test] +async fn test_align_selections(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + + // 1) one cursor, no action + let before = " abc\n abc\nabc\n ˇabc"; + cx.set_state(before); + cx.update_editor(|e, window, cx| e.align_selections(&AlignSelections, window, cx)); + cx.assert_editor_state(before); + + // 2) multiple cursors at different rows + let before = indoc!( + r#" + let aˇbc = 123; + let xˇyz = 456; + let fˇoo = 789; + let bˇar = 0; + "# + ); + let after = indoc!( + r#" + let a ˇbc = 123; + let x ˇyz = 456; + let f ˇoo = 789; + let bˇar = 0; + "# + ); + cx.set_state(before); + cx.update_editor(|e, window, cx| e.align_selections(&AlignSelections, window, cx)); + cx.assert_editor_state(after); + + // 3) multiple selections at different rows + let before = indoc!( + r#" + let «ˇabc» = 123; + let «ˇxyz» = 456; + let «ˇfoo» = 789; + let «ˇbar» = 0; + "# + ); + let after = indoc!( + r#" + let «ˇabc» = 123; + let «ˇxyz» = 456; + let «ˇfoo» = 789; + let «ˇbar» = 0; + "# + ); + cx.set_state(before); + cx.update_editor(|e, window, cx| e.align_selections(&AlignSelections, window, cx)); + cx.assert_editor_state(after); + + // 4) multiple selections at different rows, inverted head + let before = indoc!( + r#" + let «abcˇ» = 123; + // comment + let «xyzˇ» = 456; + let «fooˇ» = 789; + let «barˇ» = 0; + "# + ); + let after = indoc!( + r#" + let «abcˇ» = 123; + // comment + let «xyzˇ» = 456; + let «fooˇ» = 789; + let «barˇ» = 0; + "# + ); + cx.set_state(before); + cx.update_editor(|e, window, cx| e.align_selections(&AlignSelections, window, cx)); + cx.assert_editor_state(after); +} + +#[gpui::test] +async fn test_align_selections_multicolumn(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + + // 1) Multicolumn, one non affected editor row + let before = indoc!( + r#" + name «|ˇ» age «|ˇ» height «|ˇ» note + Matthew «|ˇ» 7 «|ˇ» 2333 «|ˇ» smart + Mike «|ˇ» 1234 «|ˇ» 567 «|ˇ» lazy + Anything that is not selected + Miles «|ˇ» 88 «|ˇ» 99 «|ˇ» funny + "# + ); + let after = indoc!( + r#" + name «|ˇ» age «|ˇ» height «|ˇ» note + Matthew «|ˇ» 7 «|ˇ» 2333 «|ˇ» smart + Mike «|ˇ» 1234 «|ˇ» 567 «|ˇ» lazy + Anything that is not selected + Miles «|ˇ» 88 «|ˇ» 99 «|ˇ» funny + "# + ); + cx.set_state(before); + cx.update_editor(|e, window, cx| e.align_selections(&AlignSelections, window, cx)); + cx.assert_editor_state(after); + + // 2) not all alignment rows has the number of alignment columns + let before = indoc!( + r#" + name «|ˇ» age «|ˇ» height + Matthew «|ˇ» 7 «|ˇ» 2333 + Mike «|ˇ» 1234 + Miles «|ˇ» 88 «|ˇ» 99 + "# + ); + let after = indoc!( + r#" + name «|ˇ» age «|ˇ» height + Matthew «|ˇ» 7 «|ˇ» 2333 + Mike «|ˇ» 1234 + Miles «|ˇ» 88 «|ˇ» 99 + "# + ); + cx.set_state(before); + cx.update_editor(|e, window, cx| e.align_selections(&AlignSelections, window, cx)); + cx.assert_editor_state(after); + + // 3) A aligned column shall stay aligned + let before = indoc!( + r#" + $ ˇa ˇa + $ ˇa ˇa + $ ˇa ˇa + $ ˇa ˇa + "# + ); + let after = indoc!( + r#" + $ ˇa ˇa + $ ˇa ˇa + $ ˇa ˇa + $ ˇa ˇa + "# + ); + cx.set_state(before); + cx.update_editor(|e, window, cx| e.align_selections(&AlignSelections, window, cx)); + cx.assert_editor_state(after); +} + +#[gpui::test] +async fn test_align_selections_with_multibyte_chars(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + + // The reported case: `←` and `π` take more bytes than they take columns, so + // aligning on the buffer column padded the first row one space too far. + let before = "a ← 1 ˇ# one\nbc ← π ˇ# two"; + let after = "a ← 1 ˇ# one\nbc ← π ˇ# two"; + cx.set_state(before); + cx.update_editor(|e, window, cx| e.align_selections(&AlignSelections, window, cx)); + cx.assert_editor_state(after); + + // A multi-byte character before the first column also has to shift the + // offset that is carried into the second column. + let before = "π aˇ bbbˇc\nxy aˇ bˇc"; + let after = "π a ˇ bbbˇc\nxy aˇ b ˇc"; + cx.set_state(before); + cx.update_editor(|e, window, cx| e.align_selections(&AlignSelections, window, cx)); + cx.assert_editor_state(after); + + // The display map expands tabs before the row is laid out, so a leading tab + // counts as its expanded width rather than as one byte. + let before = "\taˇbc\nxyzaˇbc"; + let after = "\taˇbc\nxyza ˇbc"; + cx.set_state(before); + cx.update_editor(|e, window, cx| e.align_selections(&AlignSelections, window, cx)); + cx.assert_editor_state(after); + + // A non-BMP character advances two columns, so counting characters rather + // than measuring advances would pad this row twice as far as it needs. + let before = "😀ˇb\nxyzˇb"; + let after = "😀 ˇb\nxyzˇb"; + cx.set_state(before); + cx.update_editor(|e, window, cx| e.align_selections(&AlignSelections, window, cx)); + cx.assert_editor_state(after); + + // Multi-byte characters after the cursors do not move them. + let before = "abˇ←z\ncdˇqz"; + cx.set_state(before); + cx.update_editor(|e, window, cx| e.align_selections(&AlignSelections, window, cx)); + cx.assert_editor_state(before); +} + +#[gpui::test] +async fn test_align_selections_with_soft_wrap(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + + let before = "aaaaaaaaaaaaˇx\nbbbbbˇy"; + let after = "aaaaaaaaaaaaˇx\nbbbbb ˇy"; + cx.set_state(before); + cx.update_editor(|e, _, cx| e.set_wrap_width(Some(100.0.into()), cx)); + cx.update_editor(|e, window, cx| { + assert!(e.display_text(cx).lines().count() > 2); + e.align_selections(&AlignSelections, window, cx) + }); + cx.assert_editor_state(after); +} + +#[gpui::test] +async fn test_custom_fallback_highlights(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + cx.set_state(indoc! {"fn main(self, variable: TType) {ˇ}"}); + + let variable_color = Hsla::green(); + let function_color = Hsla::blue(); + + let test_cases = [ + ("@variable", Some(variable_color)), + ("@type", None), + ("@type @variable", Some(variable_color)), + ("@variable @type", Some(variable_color)), + ("@variable @function", Some(function_color)), + ("@function @variable", Some(variable_color)), + ]; + + for (test_case, expected) in test_cases { + let custom_rust_lang = Arc::into_inner(rust_lang()) + .unwrap() + .with_highlights_query(format! {r#"(type_identifier) {test_case}"#}.as_str()) + .unwrap(); + let theme = setup_syntax_highlighting(Arc::new(custom_rust_lang), &mut cx); + let expected = expected.map_or_else(Vec::new, |expected_color| { + vec![(24..29, HighlightStyle::color(expected_color))] + }); + + cx.update_editor(|editor, window, cx| { + let snapshot = editor.snapshot(window, cx); + assert_eq!( + expected, + snapshot.combined_highlights(MultiBufferOffset(0)..snapshot.buffer().len(), &theme), + "Test case with '{test_case}' highlights query did not pass", + ); + }); + } +} + +#[gpui::test] +async fn test_tsx_nested_jsx_member_expression_highlights(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + cx.set_state("ˇ;"); + + let language = Arc::new( + Language::new( + LanguageConfig { + name: "TSX".into(), + matcher: (LanguageMatcher { + path_suffixes: vec!["tsx".to_string()], + ..LanguageMatcher::default() + }) + .into(), + ..LanguageConfig::default() + }, + Some(tree_sitter_typescript::LANGUAGE_TSX.into()), + ) + .with_highlights_query(include_str!("../../grammars/src/tsx/highlights.scm")) + .unwrap(), + ); + + let component_color = Hsla::green(); + let theme = Arc::new(SyntaxTheme::new_test(vec![ + ("tag.component.jsx", component_color), + ("type", Hsla::blue()), + ("property", Hsla::red()), + ("punctuation.bracket", Hsla::default()), + ("punctuation.delimiter", Hsla::default()), + ])); + setup_syntax_highlighting_with_theme(language, theme.clone(), &mut cx); + cx.update_editor(|editor, window, cx| { + let snapshot = editor.snapshot(window, cx); + assert_eq!( + snapshot + .combined_highlights(MultiBufferOffset(0)..snapshot.buffer().len(), &theme) + .iter() + .filter(|(_, style)| *style == HighlightStyle::color(component_color)) + .cloned() + .collect::>(), + vec![ + (1..2, HighlightStyle::color(component_color)), + (3..4, HighlightStyle::color(component_color)), + (5..6, HighlightStyle::color(component_color)), + (9..10, HighlightStyle::color(component_color)), + (11..12, HighlightStyle::color(component_color)), + (13..14, HighlightStyle::color(component_color)), + ], + ); + }); +} + +fn setup_syntax_highlighting( + language: Arc, + cx: &mut EditorTestContext, +) -> Arc { + let syntax = Arc::new(SyntaxTheme::new_test(vec![ + ("keyword", Hsla::red()), + ("function", Hsla::blue()), + ("variable", Hsla::green()), + ("number", Hsla::default()), + ("operator", Hsla::default()), + ("punctuation.bracket", Hsla::default()), + ("punctuation.delimiter", Hsla::default()), + ])); + + setup_syntax_highlighting_with_theme(language, syntax.clone(), cx); + syntax +} + +fn setup_syntax_highlighting_with_theme( + language: Arc, + syntax: Arc, + cx: &mut EditorTestContext, +) { + language.set_theme(&syntax); + + cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); + cx.executor().run_until_parked(); + cx.update_editor(|editor, window, cx| { + editor.set_style( + EditorStyle { + syntax, + ..EditorStyle::default() + }, + window, + cx, + ); + }); +} + +#[gpui::test] +async fn test_toggle_diagnostics_persists_across_settings_change(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + + cx.update_editor(|editor, _, _| { + assert!( + editor.diagnostics_enabled(), + "diagnostics should start enabled by default" + ); + }); + + cx.update_editor(|editor, window, cx| { + editor.toggle_diagnostics(&actions::ToggleDiagnostics, window, cx); + assert!( + !editor.diagnostics_enabled(), + "diagnostics should be disabled after toggle" + ); + }); + + update_test_editor_settings(&mut cx, &|settings| { + settings.cursor_blink = Some(false); + }); + cx.run_until_parked(); + + cx.update_editor(|editor, _, _| { + assert!( + !editor.diagnostics_enabled(), + "diagnostics should remain disabled after settings change" + ); + }); + + cx.update_editor(|editor, window, cx| { + editor.toggle_diagnostics(&actions::ToggleDiagnostics, window, cx); + assert!( + editor.diagnostics_enabled(), + "diagnostics should be re-enabled after second toggle" + ); + }); +} + +#[gpui::test] +async fn test_columnar_selection_with_multibyte_chars(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + // The middle row contains a 2-byte char (ã) before the dragged column. A + // column selection that uses byte columns directly puts the ã row's + // selection at a different visual position than the ASCII rows; anchoring + // in x pixels keeps all rows at the same character offset. + cx.set_state(indoc! {" + ˇabcde + abcde + aãcde + abcde + abcde + "}); + + // Drag column-wise from (row 0, col 0) past the ã column on every row. + cx.update_editor(|editor, window, cx| { + editor.select( + SelectPhase::BeginColumnar { + position: DisplayPoint::new(DisplayRow(0), 0), + goal_column: 0, + reset: true, + mode: ColumnarMode::FromMouse, + }, + window, + cx, + ); + editor.select( + SelectPhase::Update { + position: DisplayPoint::new(DisplayRow(4), 4), + goal_column: 4, + scroll_delta: gpui::Point::default(), + }, + window, + cx, + ); + }); + + cx.assert_editor_state(indoc! {" + «abcdˇ»e + «abcdˇ»e + «aãcdˇ»e + «abcdˇ»e + «abcdˇ»e + "}); + + // Control: drag stops before the ã column, where byte columns and x + // positions agree. + cx.update_editor(|editor, window, cx| { + editor.select( + SelectPhase::BeginColumnar { + position: DisplayPoint::new(DisplayRow(0), 0), + goal_column: 0, + reset: true, + mode: ColumnarMode::FromMouse, + }, + window, + cx, + ); + editor.select( + SelectPhase::Update { + position: DisplayPoint::new(DisplayRow(4), 1), + goal_column: 1, + scroll_delta: gpui::Point::default(), + }, + window, + cx, + ); + }); + + cx.assert_editor_state(indoc! {" + «aˇ»bcde + «aˇ»bcde + «aˇ»ãcde + «aˇ»bcde + «aˇ»bcde + "}); +} + +#[gpui::test] +async fn test_columnar_selection_past_end_of_line(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + cx.set_state(indoc! {" + ˇaaaaaaaaaa + bb + cccccccccc + "}); + + // Drag from the start of the long first row to a point past the EOL of + // the short second row: the mouse handlers encode that as the nearest + // valid position (1, 2) plus an unclipped goal column of 8. The rectangle + // must keep tracking the mouse x on the long row instead of collapsing to + // the short row's width. + cx.update_editor(|editor, window, cx| { + editor.select( + SelectPhase::BeginColumnar { + position: DisplayPoint::new(DisplayRow(0), 0), + goal_column: 0, + reset: true, + mode: ColumnarMode::FromMouse, + }, + window, + cx, + ); + editor.select( + SelectPhase::Update { + position: DisplayPoint::new(DisplayRow(1), 2), + goal_column: 8, + scroll_delta: gpui::Point::default(), + }, + window, + cx, + ); + }); + + cx.assert_editor_state(indoc! {" + «aaaaaaaaˇ»aa + «bbˇ» + cccccccccc + "}); + + // Starting the drag past the EOL of the short row must anchor that edge + // of the rectangle at the click position, not at the short row's EOL. + cx.update_editor(|editor, window, cx| { + editor.select( + SelectPhase::BeginColumnar { + position: DisplayPoint::new(DisplayRow(1), 2), + goal_column: 8, + reset: true, + mode: ColumnarMode::FromMouse, + }, + window, + cx, + ); + editor.select( + SelectPhase::Update { + position: DisplayPoint::new(DisplayRow(2), 4), + goal_column: 4, + scroll_delta: gpui::Point::default(), + }, + window, + cx, + ); + }); + + cx.assert_editor_state(indoc! {" + aaaaaaaaaa + bb + cccc«ˇcccc»cc + "}); +} + +#[gpui::test] +async fn test_columnar_selection_with_soft_wrap(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + cx.set_state(indoc! {" + ˇ1. Very long line to show how a wrapped line would look + 2. Very long line to show how a wrapped line would look + "}); + + let soft_wrap_second_line_row = |editor: &mut Editor, cx: &mut Context| { + editor.set_wrap_width(Some(100.0.into()), cx); + let snapshot = editor.display_snapshot(cx); + let second_line_row = Point::new(1, 0).to_display_point(&snapshot).row(); + assert!( + second_line_row.0 > 1, + "expected the first line to be soft wrapped" + ); + second_line_row + }; + + // Dragging a columnar selection across the soft-wrapped rows of the first + // line must produce one selection per buffer line, not one per display row. + cx.update_editor(|editor, window, cx| { + let second_line_row = soft_wrap_second_line_row(editor, cx); + editor.select( + SelectPhase::BeginColumnar { + position: DisplayPoint::new(DisplayRow(0), 0), + goal_column: 0, + reset: true, + mode: ColumnarMode::FromMouse, + }, + window, + cx, + ); + editor.select( + SelectPhase::Update { + position: DisplayPoint::new(second_line_row, 1), + goal_column: 1, + scroll_delta: gpui::Point::default(), + }, + window, + cx, + ); + }); + + cx.assert_editor_state(indoc! {" + «1ˇ». Very long line to show how a wrapped line would look + «2ˇ». Very long line to show how a wrapped line would look + "}); + + cx.update_editor(|editor, window, cx| { + let second_line_row = soft_wrap_second_line_row(editor, cx); + editor.select( + SelectPhase::BeginColumnar { + position: DisplayPoint::new(DisplayRow(0), 0), + goal_column: 0, + reset: true, + mode: ColumnarMode::FromMouse, + }, + window, + cx, + ); + editor.select( + SelectPhase::Update { + position: DisplayPoint::new(second_line_row, 0), + goal_column: 0, + scroll_delta: gpui::Point::default(), + }, + window, + cx, + ); + }); + + cx.assert_editor_state(indoc! {" + ˇ1. Very long line to show how a wrapped line would look + ˇ2. Very long line to show how a wrapped line would look + "}); +} + +#[gpui::test] +async fn test_toggle_markdown_block_quote(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + // No-op with no language + cx.set_state(indoc! {" + «helloˇ» world + "}); + cx.update_editor(|e, window, cx| e.toggle_markdown_block_quote(&ToggleBlockQuote, window, cx)); + cx.assert_editor_state(indoc! {" + «helloˇ» world + "}); + + // No-op in non-Markdown language (Rust) + cx.update_buffer(|buffer, cx| buffer.set_language(Some(rust_lang()), cx)); + cx.set_state(indoc! {" + «helloˇ» world + "}); + cx.update_editor(|e, window, cx| e.toggle_markdown_block_quote(&ToggleBlockQuote, window, cx)); + cx.assert_editor_state(indoc! {" + «helloˇ» world + "}); + + cx.update_buffer(|buffer, cx| buffer.set_language(Some(markdown_lang()), cx)); + + // Line is quoted with an empty selection + cx.set_state(indoc! {" + helˇlo world + "}); + cx.update_editor(|e, window, cx| e.toggle_markdown_block_quote(&ToggleBlockQuote, window, cx)); + cx.assert_editor_state(indoc! {" + «> hello worldˇ» + "}); + + // Line is unquoted with an empty selection + cx.update_editor(|e, window, cx| e.toggle_markdown_block_quote(&ToggleBlockQuote, window, cx)); + cx.assert_editor_state(indoc! {" + «hello worldˇ» + "}); + + // Multi-line selection is quoted, including blank lines + cx.set_state(indoc! {" + «first + + thirdˇ» + "}); + cx.update_editor(|e, window, cx| e.toggle_markdown_block_quote(&ToggleBlockQuote, window, cx)); + cx.assert_editor_state(indoc! {" + «> first + > + > thirdˇ» + "}); + + // Multi-line selection is unquoted, including blank lines + cx.update_editor(|e, window, cx| e.toggle_markdown_block_quote(&ToggleBlockQuote, window, cx)); + cx.assert_editor_state(indoc! {" + «first + + thirdˇ» + "}); + + // A multi-line selection, including a mixture of quoted and unquoted lines + // and a mixture of empty and non-empty lines, normalizes each line to a + // single quote. + cx.set_state(indoc! {" + «> first + second + > + + > third + >fourthˇ» + "}); + cx.update_editor(|e, window, cx| e.toggle_markdown_block_quote(&ToggleBlockQuote, window, cx)); + cx.assert_editor_state(indoc! {" + «> first + > second + > + > + > third + > fourthˇ» + "}); + + // A multi-line selection is unquoted. + cx.update_editor(|e, window, cx| e.toggle_markdown_block_quote(&ToggleBlockQuote, window, cx)); + cx.assert_editor_state(indoc! {" + «first + second + + + third + fourthˇ» + "}); +} + +#[track_caller] +fn assert_select_delimiters(around: bool, before: &str, after: &str, cx: &mut EditorTestContext) { + let _state_context = cx.set_state(before); + + if around { + cx.dispatch_action(SelectAroundDelimiters); + } else { + cx.dispatch_action(SelectInsideDelimiters); + } + + cx.assert_editor_state(after); +} + +#[gpui::test] +async fn test_select_delimiters(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorLspTestContext::new_typescript(Default::default(), cx).await; + + // Inside. + assert_select_delimiters(false, "foo(ˇbar);", "foo(«barˇ»);", &mut cx); + assert_select_delimiters(false, "foo(a, ˇb, c);", "foo(«a, b, cˇ»);", &mut cx); + assert_select_delimiters(false, "foo([1, ˇ2, 3]);", "foo([«1, 2, 3ˇ»]);", &mut cx); + assert_select_delimiters(false, "let x = { aˇ: 1 };", "let x = {« a: 1 ˇ»};", &mut cx); + assert_select_delimiters(false, "let xˇ = 42;", "let xˇ = 42;", &mut cx); + assert_select_delimiters(false, "foo(a, «bˇ», c);", "foo(«a, b, cˇ»);", &mut cx); + assert_select_delimiters( + false, + "const s = \"hello ˇworld\";", + "const s = \"«hello worldˇ»\";", + &mut cx, + ); + + assert_select_delimiters( + false, + "const s = \"ˇhello world\";", + "const s = \"«hello worldˇ»\";", + &mut cx, + ); + + assert_select_delimiters( + false, + "const s = \"hello worldˇ\";", + "const s = \"«hello worldˇ»\";", + &mut cx, + ); + + assert_select_delimiters( + false, + "console.log(\"deˇbug\");", + "console.log(\"«debugˇ»\");", + &mut cx, + ); + + // Around. + assert_select_delimiters(true, "foo(ˇbar);", "foo«(bar)ˇ»;", &mut cx); + assert_select_delimiters(true, "foo([1, ˇ2, 3]);", "foo(«[1, 2, 3]ˇ»);", &mut cx); + assert_select_delimiters(true, "let x = {ˇ a: 1 };", "let x = «{ a: 1 }ˇ»;", &mut cx); + assert_select_delimiters(true, "let xˇ = 42;", "let xˇ = 42;", &mut cx); + assert_select_delimiters( + true, + "console.log(\"deˇbug\");", + "console.log(«\"debug\"ˇ»);", + &mut cx, + ); +} + +#[gpui::test] +async fn test_select_delimiters_in_markdown(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + cx.update_buffer(|buffer, cx| buffer.set_language(Some(markdown_lang()), cx)); + + // Inside. + assert_select_delimiters( + false, + r#"This is "ˇhello, world!"."#, + r#"This is "«hello, world!ˇ»"."#, + &mut cx, + ); + assert_select_delimiters( + false, + r#"This is "hello, ˇworld!"."#, + r#"This is "«hello, world!ˇ»"."#, + &mut cx, + ); + assert_select_delimiters( + false, + r#"This is "hello, world!ˇ"."#, + r#"This is "«hello, world!ˇ»"."#, + &mut cx, + ); + assert_select_delimiters( + false, + r#"This is ˇ"hello, world!"."#, + r#"This is "«hello, world!ˇ»"."#, + &mut cx, + ); + assert_select_delimiters( + false, + r#"This is "hello, world!"ˇ."#, + r#"This is "«hello, world!ˇ»"."#, + &mut cx, + ); + assert_select_delimiters( + false, + r#"This is 'hello, ˇworld!'."#, + r#"This is '«hello, world!ˇ»'."#, + &mut cx, + ); + assert_select_delimiters( + false, + r#"This is `hello, ˇworld!`."#, + r#"This is `«hello, world!ˇ»`."#, + &mut cx, + ); + assert_select_delimiters( + false, + r#"This is ("hello, ˇworld!")."#, + r#"This is ("«hello, world!ˇ»")."#, + &mut cx, + ); + assert_select_delimiters( + false, + r#"This is hello, ˇworld!."#, + r#"This is hello, ˇworld!."#, + &mut cx, + ); + + // Around. + assert_select_delimiters( + true, + r#"This is "hello, ˇworld!"."#, + r#"This is «"hello, world!"ˇ»."#, + &mut cx, + ); + assert_select_delimiters( + true, + r#"This is 'hello, ˇworld!'."#, + r#"This is «'hello, world!'ˇ»."#, + &mut cx, + ); + assert_select_delimiters( + true, + r#"This is `hello, ˇworld!`."#, + r#"This is «`hello, world!`ˇ»."#, + &mut cx, + ); +} + +#[gpui::test] +async fn test_select_delimiters_expansion(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorLspTestContext::new_typescript(Default::default(), cx).await; + + let _state_context = cx.set_state("foo([1, ˇ2, 3]);"); + cx.dispatch_action(SelectInsideDelimiters); + cx.assert_editor_state("foo([«1, 2, 3ˇ»]);"); + cx.dispatch_action(SelectInsideDelimiters); + cx.assert_editor_state("foo(«[1, 2, 3]ˇ»);"); + + let _state_context = cx.set_state("foo([1, ˇ2, 3]);"); + cx.dispatch_action(SelectInsideDelimiters); + cx.assert_editor_state("foo([«1, 2, 3ˇ»]);"); + cx.dispatch_action(SelectAroundDelimiters); + cx.assert_editor_state("foo(«[1, 2, 3]ˇ»);"); + cx.dispatch_action(SelectAroundDelimiters); + cx.assert_editor_state("foo«([1, 2, 3])ˇ»;"); + + let _state_context = cx.set_state("foo(x, { ˇa: 1 });"); + cx.dispatch_action(SelectInsideDelimiters); + cx.assert_editor_state("foo(x, {« a: 1 ˇ»});"); + cx.dispatch_action(SelectAroundDelimiters); + cx.assert_editor_state("foo(x, «{ a: 1 }ˇ»);"); + cx.dispatch_action(SelectInsideDelimiters); + cx.assert_editor_state("foo(«x, { a: 1 }ˇ»);"); +} + +#[gpui::test] +async fn test_in_preview_key_context(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + cx.update(|cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings: &mut SettingsContent| { + settings.preview_tabs.get_or_insert_default().enabled = Some(true); + }); + }); + }); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/root"), + serde_json::json!({ + "test.txt": "hello", + }), + ) + .await; + let project = Project::test(fs, [path!("/root").as_ref()], cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); + let worktree_id = workspace.update(cx, |workspace, cx| { + workspace.project().update(cx, |project, cx| { + project.worktrees(cx).next().unwrap().read(cx).id() + }) + }); + + let editor = workspace + .update_in(cx, |workspace, window, cx| { + workspace.open_path_preview( + (worktree_id, rel_path("test.txt")), + None, + true, + true, + true, + window, + cx, + ) + }) + .unwrap() + .await + .downcast::() + .unwrap(); + + let pane = workspace.update(cx, |workspace, _| workspace.active_pane().clone()); + pane.read_with(cx, |pane, _| { + assert!(pane.is_active_preview_item(editor.entity_id())); + }); + + cx.focus(&editor); + editor.update_in(cx, |editor, window, cx| { + assert!( + editor.key_context(window, cx).contains("in_preview"), + "Expected 'in_preview' context when the editor is the pane's preview item" + ); + }); + + pane.update(cx, |pane, _cx| { + pane.unpreview_item_if_preview(editor.entity_id()); + }); + + editor.update_in(cx, |editor, window, cx| { + assert!( + !editor.key_context(window, cx).contains("in_preview"), + "Expected no 'in_preview' context after the tab is unpreviewed" + ); + }); +} + +async fn setup_range_format_test_with_capabilities( + cx: &mut TestAppContext, + capabilities: lsp::ServerCapabilities, +) -> ( + Entity, + Entity, + &mut gpui::VisualTestContext, + lsp::FakeLanguageServer, +) { + init_test(cx, |_| {}); + + let fs = FakeFs::new(cx.executor()); + fs.insert_file(path!("/file.rs"), Default::default()).await; + + let project = Project::test(fs, [path!("/").as_ref()], cx).await; + + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(rust_lang()); + let mut fake_servers = language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + capabilities, + ..FakeLspAdapter::default() + }, + ); + + let buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/file.rs"), cx) + }) + .await + .unwrap(); + + let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + let (editor, cx) = cx.add_window_view(|window, cx| { + build_editor_with_project(project.clone(), buffer, window, cx) + }); + editor.update_in(cx, |editor, window, cx| { + window.focus(&editor.focus_handle(cx), cx); + }); + + let fake_server = fake_servers.next().await.unwrap(); + + (project, editor, cx, fake_server) +} + +async fn setup_range_format_test( + cx: &mut TestAppContext, +) -> ( + Entity, + Entity, + &mut gpui::VisualTestContext, + lsp::FakeLanguageServer, +) { + setup_range_format_test_with_capabilities( + cx, + lsp::ServerCapabilities { + document_range_formatting_provider: Some(lsp::OneOf::Left(true)), + ..lsp::ServerCapabilities::default() + }, + ) + .await +} + +/// Like `setup_range_format_test`, but backs the buffer with a FakeFs git +/// repository so that `GitStore::get_unstaged_diff` returns a real diff. +/// `head_content` sets the HEAD base, `index_content` sets the staged base. +/// The buffer starts empty; the caller must `editor.set_text(...)` to set the +/// working-tree content (the diff recomputes from buffer changes). +async fn setup_range_format_test_with_git<'a>( + cx: &'a mut TestAppContext, + head_content: &str, + index_content: &str, +) -> ( + Entity, + Entity, + &'a mut gpui::VisualTestContext, + lsp::FakeLanguageServer, +) { + init_test(cx, |_| {}); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/project"), + json!({ + ".git": {}, + "file.rs": "", + }), + ) + .await; + + fs.set_head_for_repo( + std::path::Path::new(path!("/project/.git")), + &[("file.rs", head_content.to_string())], + "deadbeef", + ); + fs.set_index_for_repo( + std::path::Path::new(path!("/project/.git")), + &[("file.rs", index_content.to_string())], + ); + + let project = Project::test(fs, [path!("/project").as_ref()], cx).await; + + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(rust_lang()); + let mut fake_servers = language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + capabilities: lsp::ServerCapabilities { + document_range_formatting_provider: Some(lsp::OneOf::Left(true)), + document_formatting_provider: Some(lsp::OneOf::Left(true)), + ..lsp::ServerCapabilities::default() + }, + ..FakeLspAdapter::default() + }, + ); + + let buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/project/file.rs"), cx) + }) + .await + .unwrap(); + + // Open the unstaged diff so GitStore tracks this buffer. Without this, + // `get_unstaged_diff` returns None and compute_format_target cannot + // produce range-based FormatTarget. + project + .update(cx, |project, cx| { + project.open_unstaged_diff(buffer.clone(), cx) + }) + .await + .unwrap(); + + let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + let (editor, cx) = cx.add_window_view(|window, cx| { + build_editor_with_project(project.clone(), buffer, window, cx) + }); + editor.update_in(cx, |editor, window, cx| { + window.focus(&editor.focus_handle(cx), cx); + }); + + let fake_server = fake_servers.next().await.unwrap(); + + (project, editor, cx, fake_server) +} + +fn refresh_editor_actions(cx: &mut VisualTestContext) { + cx.executor().run_until_parked(); + cx.update(|window, cx| { + let _ = window.draw(cx); + }); +} + +fn lsp_line_edit(start_line: u32, end_line: u32, text: &str) -> lsp::TextEdit { + lsp::TextEdit::new( + lsp::Range::new( + lsp::Position::new(start_line, 0), + lsp::Position::new(end_line, 0), + ), + text.to_string(), + ) +} + +async fn assert_range_format_merge( + cx: &mut TestAppContext, + head_content: &str, + buffer_text: &str, + responses: Vec>, + expected_text: &str, + description: &str, +) { + let expected_requests = responses.len(); + let (project, editor, cx, fake_server) = + setup_range_format_test_with_git(cx, head_content, head_content).await; + + update_test_language_settings(cx, &|settings| { + settings.defaults.format_on_save = Some(FormatOnSave::Modifications); + }); + + editor.update_in(cx, |editor, window, cx| { + editor.set_text(buffer_text, window, cx); + }); + cx.run_until_parked(); + assert!(cx.read(|cx| editor.is_dirty(cx))); + + let request_count = Arc::new(AtomicUsize::new(0)); + let responses = Arc::new(responses); + let mut responded_rx = + fake_server.set_request_handler::({ + let request_count = request_count.clone(); + move |params, _| { + let count = request_count.fetch_add(1, atomic::Ordering::SeqCst); + let responses = responses.clone(); + async move { + assert_eq!( + params.text_document.uri, + lsp::Uri::from_file_path(path!("/project/file.rs")).unwrap() + ); + match responses.get(count) { + Some(edits) => Ok(Some(edits.clone())), + None => panic!("unexpected range formatting request #{}", count + 1), + } + } + } + }); + + let save = editor + .update_in(cx, |editor, window, cx| { + editor.save( + SaveOptions { + format: true, + autosave: false, + force_format: false, + }, + project.clone(), + window, + cx, + ) + }) + .unwrap(); + for _ in 0..expected_requests { + responded_rx.next().await; + } + save.await; + + assert_eq!( + request_count.load(atomic::Ordering::SeqCst), + expected_requests, + "{description}" + ); + assert_eq!( + editor.update(cx, |editor, cx| editor.text(cx)), + expected_text, + "{description}" + ); + assert!(!cx.read(|cx| editor.is_dirty(cx))); +} + +async fn init_bookmarks_tab_test( + cx: &mut TestAppContext, + files: serde_json::Value, +) -> ( + Entity, + Entity, + Entity, + Entity, + VisualTestContext, +) { + init_test(cx, |_| {}); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/a"), files).await; + let project = Project::test(fs, [path!("/a").as_ref()], cx).await; + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone()) + .expect("window should still be open"); + let mut cx = VisualTestContext::from_window(*window, cx); + let editor = open_bookmarks_test_editor(&workspace, &project, "main.rs", &mut cx).await; + let pane = workspace.update(&mut cx, |workspace, _| workspace.active_pane().clone()); + (workspace, pane, project, editor, cx) +} + +async fn open_bookmarks_test_editor( + workspace: &Entity, + project: &Entity, + path: &str, + cx: &mut VisualTestContext, +) -> Entity { + let worktree_id = project.update(cx, |project, cx| { + project + .worktrees(cx) + .next() + .expect("project should have at least one worktree") + .read(cx) + .id() + }); + let buffer = project + .update(cx, |project, cx| { + project.open_buffer((worktree_id, rel_path(path)), cx) + }) + .await + .expect("buffer should open"); + let editor = workspace.update_in(cx, |_workspace, window, cx| { + let multibuffer = MultiBuffer::build_from_buffer(buffer, cx); + cx.new(|cx| { + Editor::new( + EditorMode::full(), + multibuffer, + Some(project.clone()), + window, + cx, + ) + }) + }); + let pane = workspace.update(cx, |workspace, _| workspace.active_pane().clone()); + pane.update_in(cx, |pane, window, cx| { + pane.add_item(Box::new(editor.clone()), true, true, None, window, cx); + }); + editor +} + +fn open_bookmarks_tab(workspace: &Entity, cx: &mut VisualTestContext) { + workspace.update_in(cx, |workspace, window, cx| { + Editor::view_bookmarks(workspace, &actions::ViewBookmarks, window, cx); + }); +} + +fn toggle_bookmark_on_row(editor: &Entity, row: u32, cx: &mut VisualTestContext) { + editor.update_in(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_ranges([Point::new(row, 0)..Point::new(row, 0)]) + }); + editor.toggle_bookmark(&actions::ToggleBookmark, window, cx); + }); +} + +fn find_bookmarks_editor( + workspace: &Entity, + cx: &mut VisualTestContext, +) -> Option> { + workspace.update(cx, |workspace, cx| { + workspace + .items_of_type::(cx) + .find(|editor| editor.read(cx).bookmarks_tab_state.is_some()) + }) +} + +fn excerpt_count(editor: &Entity, cx: &mut VisualTestContext) -> usize { + editor.update(cx, |editor, cx| { + editor.buffer.read(cx).snapshot(cx).excerpts().count() + }) +} + +fn bookmarked_paths(editor: &Entity, cx: &mut VisualTestContext) -> Vec { + editor.update(cx, |editor, cx| { + let mut paths = editor + .buffer + .read(cx) + .snapshot(cx) + .buffers_with_paths() + .map(|(_, path_key)| path_key.path.as_unix_str().to_string()) + .collect::>(); + paths.sort(); + paths + }) +} + +fn cursor_line_text(editor: &Entity, cx: &mut VisualTestContext) -> String { + editor.update(cx, |editor, cx| { + let snapshot = editor.buffer.read(cx).snapshot(cx); + let head = editor + .selections + .newest::(&editor.display_snapshot(cx)) + .head(); + snapshot + .text_for_range(Point::new(head.row, 0)..Point::new(head.row + 1, 0)) + .collect::() + .trim_end() + .to_string() + }) +} + +fn close_bookmarks_editor( + pane: &Entity, + bookmarks_editor: Entity, + cx: &mut VisualTestContext, +) { + let weak_bookmarks_editor = bookmarks_editor.downgrade(); + pane.update_in(cx, |pane, window, cx| { + pane.remove_item(weak_bookmarks_editor.entity_id(), false, false, window, cx); + }); + drop(bookmarks_editor); + cx.executor() + .advance_clock(workspace::SERIALIZATION_THROTTLE_TIME); + cx.run_until_parked(); + cx.update(|_window, _cx| {}); + weak_bookmarks_editor.assert_released(); +} + +fn open_and_find_bookmarks_tab( + workspace: &Entity, + cx: &mut VisualTestContext, +) -> Entity { + open_bookmarks_tab(workspace, cx); + cx.run_until_parked(); + find_bookmarks_editor(workspace, cx) + .expect("bookmarks editor should exist after view_bookmarks") +} + +fn bookmark_path(path: &str) -> Arc { + Arc::from(std::path::Path::new(path)) +} + +fn serialized_rows(rows: &[u32]) -> Vec { + rows.iter() + .map(|&row| SerializedBookmark { + row, + label: String::new(), + }) + .collect() +} + +async fn load_test_bookmarks( + bookmark_store: &Entity, + entries: Vec<(Arc, Vec)>, + cx: &mut VisualTestContext, +) { + let entries = entries + .into_iter() + .map(|(path, rows)| (path, serialized_rows(&rows))) + .collect::>(); + bookmark_store + .update(cx, |store, cx| store.load_serialized_bookmarks(entries, cx)) + .await + .expect("loading serialized bookmarks should succeed"); +} + +fn failed_paths( + bookmark_store: &Entity, + cx: &mut VisualTestContext, +) -> Vec> { + bookmark_store.read_with(cx, |store, _| { + let mut paths = store + .paths_failed_to_open() + .iter() + .cloned() + .collect::>(); + paths.sort(); + paths + }) +} + +fn highlighted_display_rows_of( + editor: &Entity, + cx: &mut VisualTestContext, +) -> Vec { + editor.update_in(cx, |editor, window, cx| { + editor + .highlighted_display_rows(window, cx) + .into_keys() + .collect() + }) +} + +fn display_row_for( + editor: &Entity, + point: Point, + cx: &mut VisualTestContext, +) -> DisplayRow { + editor.update(cx, |editor, cx| { + editor + .display_snapshot(cx) + .point_to_display_point(point, text::Bias::Left) + .row() + }) +} + +fn last_display_row(editor: &Entity, cx: &mut VisualTestContext) -> DisplayRow { + let last_point = editor.update(cx, |editor, cx| { + editor.buffer.read(cx).snapshot(cx).max_point() + }); + display_row_for(editor, last_point, cx) +} +#[gpui::test] +async fn test_scroll_range_hold_freezes_before_first_settled_frame(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + + cx.update_editor(|editor, _, cx| { + let bounds = size(px(1800.), px(900.)); + let settled = |width: f32, height: f32, editor_width: f32, bounds: Size| { + Some(SettledScrollRange { + range: size(px(width), px(height)), + editor_width: px(editor_width), + editor_bounds_size: bounds, + }) + }; + editor.set_search_results_status( + SearchResultsStatus { + pending: true, + ..SearchResultsStatus::default() + }, + cx, + ); + assert_eq!( + editor.frozen_scroll_range(false, size(px(1780.), px(100.)), px(1786.), bounds), + settled(1780., 100., 1786., bounds), + "a hold that engages before any settled frame must freeze on the first \ + state it sees instead of falling through to the live churning range" + ); + assert_eq!( + editor.frozen_scroll_range(true, size(px(9000.), px(400.)), px(1776.), bounds), + settled(1780., 100., 1786., bounds), + "an unwrapped interpolation spike and a gutter-driven viewport change \ + mid-churn must not leak into the held state; comparing a settled width \ + against a viewport from another gutter regime blinks the scrollbar" + ); + assert_eq!( + editor.frozen_scroll_range(false, size(px(1770.), px(160.)), px(1776.), bounds), + settled(1780., 100., 1786., bounds), + "while held, the settled pair stays even when the live state shrinks" + ); + let resized_bounds = size(px(1300.), px(900.)); + assert_eq!( + editor.frozen_scroll_range(true, size(px(1300.), px(160.)), px(1276.), resized_bounds), + settled(1300., 160., 1276., resized_bounds), + "a window resize invalidates the frozen state even mid-churn, otherwise the \ + scrollbar thumbs stay sized for the old window until the search settles" + ); + assert_eq!( + editor.frozen_scroll_range(true, size(px(9000.), px(400.)), px(1276.), resized_bounds), + settled(1300., 160., 1276., resized_bounds), + "after re-freezing on the resize, churn at the same bounds stays frozen again" + ); + editor.set_search_results_status(SearchResultsStatus::default(), cx); + assert_eq!( + editor.frozen_scroll_range(false, size(px(1770.), px(160.)), px(1776.), bounds), + None, + "once released and not rewrapping, the live state settles" + ); + assert_eq!( + editor.frozen_scroll_range(true, size(px(9000.), px(400.)), px(1786.), bounds), + settled(1770., 160., 1776., bounds), + "a rewrap after release keeps the last settled pair frozen" + ); + }); +} + +#[gpui::test] +async fn test_lsp_show_document(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorLspTestContext::new_rust(lsp::ServerCapabilities::default(), cx).await; + + let target_path = EditorLspTestContext::root_path() + .join("dir") + .join("target.rs"); + let fs = cx.update_workspace(|workspace, _, cx| workspace.project().read(cx).fs().clone()); + fs.as_fake() + .insert_file(&target_path, b"fn target() {}".to_vec()) + .await; + + let response = cx + .lsp + .server + .request::( + lsp::ShowDocumentParams { + uri: lsp::Uri::from_file_path(&target_path).unwrap(), + external: None, + take_focus: Some(true), + selection: Some(lsp::Range::new( + lsp::Position::new(0, 3), + lsp::Position::new(0, 9), + )), + }, + DEFAULT_LSP_REQUEST_TIMEOUT, + ) + .await + .into_response() + .expect("show document request should not error"); + assert_eq!(response, lsp::ShowDocumentResult { success: true }); + cx.run_until_parked(); + + cx.update_workspace(|workspace, _, cx| { + let editor = workspace + .active_item_as::(cx) + .expect("an editor should be opened for the shown document"); + editor.update(cx, |editor, cx| { + let path = editor + .buffer() + .read(cx) + .as_singleton() + .expect("a singleton buffer should be opened") + .read(cx) + .file() + .expect("the opened buffer should have a file") + .path() + .clone(); + assert_eq!(path.as_ref(), rel_path("dir/target.rs")); + assert_eq!( + editor + .selections + .ranges::(&editor.display_snapshot(cx)), + vec![Point::new(0, 3)..Point::new(0, 9)] + ); + }); + }); +} + +#[gpui::test] +async fn test_lsp_show_document_external(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorLspTestContext::new_rust(lsp::ServerCapabilities::default(), cx).await; + + let initial_item_id = cx + .update_workspace(|workspace, _, cx| workspace.active_item(cx).map(|item| item.item_id())); + let response = cx + .lsp + .server + .request::( + lsp::ShowDocumentParams { + uri: "https://zed.dev/docs".parse::().unwrap(), + external: Some(true), + take_focus: None, + selection: None, + }, + DEFAULT_LSP_REQUEST_TIMEOUT, + ) + .await + .into_response() + .expect("show document request should not error"); + assert_eq!(response, lsp::ShowDocumentResult { success: true }); + assert_eq!(cx.opened_url(), Some("https://zed.dev/docs".to_string())); + cx.run_until_parked(); + cx.update_workspace(|workspace, _, cx| { + assert_eq!( + workspace.active_item(cx).map(|item| item.item_id()), + initial_item_id, + "external documents should not open any workspace items" + ); + }); +} + +#[gpui::test] +async fn test_lsp_show_document_without_take_focus(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorLspTestContext::new_rust(lsp::ServerCapabilities::default(), cx).await; + + let target_path = EditorLspTestContext::root_path() + .join("dir") + .join("target.rs"); + let fs = cx.update_workspace(|workspace, _, cx| workspace.project().read(cx).fs().clone()); + fs.as_fake() + .insert_file(&target_path, b"fn target() {}".to_vec()) + .await; + + let initial_editor = cx.editor.clone(); + let response = cx + .lsp + .server + .request::( + lsp::ShowDocumentParams { + uri: lsp::Uri::from_file_path(&target_path).unwrap(), + external: None, + take_focus: None, + selection: None, + }, + DEFAULT_LSP_REQUEST_TIMEOUT, + ) + .await + .into_response() + .expect("show document request should not error"); + assert_eq!(response, lsp::ShowDocumentResult { success: true }); + cx.run_until_parked(); + + cx.update_workspace(|workspace, _, cx| { + let opened_editor = workspace + .active_item_as::(cx) + .expect("an editor should be opened for the shown document"); + assert_ne!( + opened_editor.entity_id(), + initial_editor.entity_id(), + "the shown document should become the active item" + ); + }); +} + +#[gpui::test] +async fn test_lsp_show_document_unsupported_uri(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let cx = EditorLspTestContext::new_rust(lsp::ServerCapabilities::default(), cx).await; + + let response = cx + .lsp + .server + .request::( + lsp::ShowDocumentParams { + uri: "untitled:some-document".parse::().unwrap(), + external: None, + take_focus: None, + selection: None, + }, + DEFAULT_LSP_REQUEST_TIMEOUT, + ) + .await + .into_response() + .expect("show document request should not error"); + assert_eq!(response, lsp::ShowDocumentResult { success: false }); +} + +async fn register_document_highlight_capability( + lsp: &lsp::FakeLanguageServer, + registration_id: &str, + language: &str, +) { + lsp.request::( + lsp::RegistrationParams { + registrations: vec![lsp::Registration { + id: registration_id.to_string(), + method: "textDocument/documentHighlight".to_string(), + register_options: Some(json!({ + "documentSelector": [{ "language": language, "scheme": "file" }], + })), + }], + }, + DEFAULT_LSP_REQUEST_TIMEOUT, + ) + .await + .into_response() + .unwrap(); +} + +fn document_highlight_count(cx: &mut EditorLspTestContext) -> usize { + cx.editor(|editor, _, _| { + editor + .background_highlights + .get(&HighlightKey::DocumentHighlightRead) + .map_or(0, |(_, ranges)| ranges.len()) + }) +} + +#[track_caller] +fn assert_add_selection_pair( + editor: &mut Editor, + columns: [Range; 2], + above: bool, + reversed: bool, + window: &mut Window, + cx: &mut Context, +) { + let ranges = columns + .into_iter() + .enumerate() + .map(|(row, columns)| { + Point::new(row as u32, columns.start)..Point::new(row as u32, columns.end) + }) + .collect::>(); + let source = &ranges[usize::from(above)]; + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| { + selections.select_ranges([if reversed { + source.end..source.start + } else { + source.clone() + }]); + }); + add_selection_in_direction(editor, above, true, window, cx); + let selections = editor.selections.all::(&editor.display_snapshot(cx)); + assert_eq!( + selections + .iter() + .map(|selection| (selection.range(), selection.reversed)) + .collect::>(), + ranges + .into_iter() + .map(|range| { + let reversed = reversed && !range.is_empty(); + (range, reversed) + }) + .collect::>(), + "above={above}, reversed={reversed}", + ); +} + +#[track_caller] +fn assert_add_selection_ranges( + editor: &mut Editor, + expected: &[Range], + cx: &mut Context, +) { + assert_eq!( + editor + .selections + .ranges::(&editor.display_snapshot(cx)), + expected, + ); +} + +fn stored_selection_ranges(editor: &Editor, cx: &App) -> Vec> { + let buffer = editor.buffer.read(cx).snapshot(cx); + editor + .selections + .disjoint_anchors() + .iter() + .chain(editor.selections.pending_anchor()) + .map(|selection| selection.range().to_point(&buffer)) + .collect() +} + +fn add_selection_in_direction( + editor: &mut Editor, + above: bool, + skip_soft_wrap: bool, + window: &mut Window, + cx: &mut Context, +) { + if above { + editor.add_selection_above(&AddSelectionAbove { skip_soft_wrap }, window, cx); + } else { + editor.add_selection_below(&AddSelectionBelow { skip_soft_wrap }, window, cx); + } +} + +fn begin_add_selection_overlap( + editor: &mut Editor, + row: u32, + columns: Range, + window: &mut Window, + cx: &mut Context, +) { + editor.begin_selection( + DisplayPoint::new(DisplayRow(row), columns.start), + true, + 1, + window, + cx, + ); + editor.update_selection( + DisplayPoint::new(DisplayRow(row), columns.end), + 0, + gpui::Point::::default(), + window, + cx, + ); +} + +fn multiline_add_selection_history_states() -> [(bool, &'static str, &'static str, &'static str); 2] +{ + [ + ( + false, + "a«bc\ndefgˇ»hi\nuvwxyz\nabcdef", + "a«bcˇ»\nd«efgˇ»hi\nu«vwxˇ»yz\nabcdef", + "a«bcˇ»\nd«efgˇ»hi\nu«vwxˇ»yz\na«bcdˇ»ef", + ), + ( + true, + "abcdef\nuvwxyz\ndefg«hi\naˇ»bc", + "abcdef\nu«vwxˇ»yz\nd«efgˇ»hi\na«bcˇ»", + "a«bcdˇ»ef\nu«vwxˇ»yz\nd«efgˇ»hi\na«bcˇ»", + ), + ] +} + +#[gpui::test] +async fn test_display_row_for_inline_code_action(cx: &mut gpui::TestAppContext) { + init_test(cx, |_| {}); + + update_test_language_settings(cx, &|settings| { + settings.defaults.soft_wrap = Some(language::language_settings::SoftWrap::Bounded); + settings.defaults.soft_wrap_indent = + Some(language::language_settings::SoftWrapIndent::None); + settings.defaults.preferred_line_length = Some(25); + }); + + // 20 spaces indent, then "123456789" + let text = " 123456789"; + let editor = cx.add_window(|window, cx| { + build_editor( + multi_buffer::MultiBuffer::build_simple(text, cx), + window, + cx, + ) + }); + + let snapshot = editor + .update(cx, |editor, window, cx| editor.snapshot(window, cx)) + .unwrap(); + + // The text wraps after 25 columns. + // Indent is 20, plus "12345" is 25. So it wraps before "6789". + // Buffer point at column 26 (the '7'). + let buffer_point = Point::new(0, 26); + + let display_row = snapshot.display_row_for_inline_code_action(buffer_point); + + // With SoftWrapIndent::None, the wrapped line has 0 indent (0 < 4), + // so it should snap back to the start of the physical line (DisplayRow 0). + assert_eq!(display_row, Some(DisplayRow(0))); + + // Change to SoftWrapIndent::Same, which maintains the 20-space indent + update_test_language_settings(cx, &|settings| { + settings.defaults.soft_wrap = Some(language::language_settings::SoftWrap::Bounded); + settings.defaults.soft_wrap_indent = + Some(language::language_settings::SoftWrapIndent::Same); + settings.defaults.preferred_line_length = Some(25); + }); + + let snapshot = editor + .update(cx, |editor, window, cx| editor.snapshot(window, cx)) + .unwrap(); + + let display_row = snapshot.display_row_for_inline_code_action(buffer_point); + + // With SoftWrapIndent::Same, there is enough space in the gutter, + // so it should render on the wrapped display row (DisplayRow 1). + assert_eq!(display_row, Some(DisplayRow(1))); + + // 2 spaces of indent at column 0 (< 4), but ExtraTwo wrap adds extra indent (2 + 4 = 6 >= 4) on wrapped line + update_test_language_settings(cx, &|settings| { + settings.defaults.soft_wrap = Some(language::language_settings::SoftWrap::Bounded); + settings.defaults.soft_wrap_indent = + Some(language::language_settings::SoftWrapIndent::ExtraTwo); + settings.defaults.tab_size = std::num::NonZeroU32::new(2); + settings.defaults.preferred_line_length = Some(25); + }); + + let text_with_2_spaces = " 12345678901234567890123456789\n123456789"; + let editor = cx.add_window(|window, cx| { + build_editor( + multi_buffer::MultiBuffer::build_simple(text_with_2_spaces, cx), + window, + cx, + ) + }); + + let snapshot = editor + .update(cx, |editor, window, cx| editor.snapshot(window, cx)) + .unwrap(); + + let buffer_point = Point::new(0, 26); + let display_row = snapshot.display_row_for_inline_code_action(buffer_point); + + // Physical line 0 has 2 spaces indent (< 4), but wrapped DisplayRow(1) has 6 spaces (>= 4). + // The code action helper should stay on physical line 0 and render on DisplayRow(1). + assert_eq!(display_row, Some(DisplayRow(1))); +} + +#[gpui::test] +async fn test_display_row_for_inline_code_action_with_block_above(cx: &mut gpui::TestAppContext) { + init_test(cx, |_| {}); + + update_test_language_settings(cx, &|settings| { + settings.defaults.soft_wrap = Some(language::language_settings::SoftWrap::Bounded); + settings.defaults.soft_wrap_indent = + Some(language::language_settings::SoftWrapIndent::ExtraOne); + settings.defaults.preferred_line_length = Some(25); + }); + + let text = "1234567890123456789012345678"; + let editor = cx.add_window(|window, cx| { + build_editor( + multi_buffer::MultiBuffer::build_simple(text, cx), + window, + cx, + ) + }); + + _ = editor.update(cx, |editor, _window, cx| { + let buffer_snapshot = editor.buffer.read(cx).snapshot(cx); + editor.insert_blocks( + [BlockProperties { + style: BlockStyle::Fixed, + placement: BlockPlacement::Above(buffer_snapshot.anchor_before(Point::new(0, 0))), + height: Some(1), + render: Arc::new(|_| div().into_any()), + priority: 0, + }], + None, + cx, + ); + }); + + let snapshot = editor + .update(cx, |editor, window, cx| editor.snapshot(window, cx)) + .unwrap(); + + // DisplayRow 0 is the block, DisplayRow 1 is the first text row. + // The text wraps at column 25 with ExtraOne (4 spaces continuation). + // Cursor at column 5 is on the first text row (DisplayRow 1), not a + // continuation. The line has 0 indent (< 4) and the cursor is not on a + // wrapped continuation, so no valid row exists in this single-line buffer. + assert_eq!( + snapshot.display_row_for_inline_code_action(Point::new(0, 5)), + None + ); + + // Cursor at column 26 is on the soft-wrapped continuation row (DisplayRow 2). + // It has 4 spaces of continuation indent (>= 4), so the code action is placed here. + assert_eq!( + snapshot.display_row_for_inline_code_action(Point::new(0, 26)), + Some(DisplayRow(2)) + ); +} + +#[gpui::test] +async fn test_soft_wrap_indent_updated_on_language_changed(cx: &mut gpui::TestAppContext) { + // Configure Rust to have a different continuation indent behavior than the default. + init_test(cx, |settings| { + settings.defaults.soft_wrap = Some(language::language_settings::SoftWrap::Bounded); + settings.defaults.soft_wrap_indent = + Some(language::language_settings::SoftWrapIndent::Same); + settings.defaults.preferred_line_length = Some(20); + settings.languages.0.insert( + "Rust".into(), + LanguageSettingsContent { + soft_wrap_indent: Some(language::language_settings::SoftWrapIndent::None), + ..Default::default() + }, + ); + }); + + let mut cx = EditorTestContext::new(cx).await; + cx.set_state("ˇ let a_long_variable = 123456789;\n"); + + cx.update_editor(|editor, _window, cx| { + assert_eq!( + editor.soft_wrap_indent(cx), + language::language_settings::SoftWrapIndent::Same + ); + let snapshot = editor.display_snapshot(cx); + assert_eq!(snapshot.soft_wrap_indent(DisplayRow(0)), Some(4)); + }); + + cx.update_buffer(|buffer, cx| { + buffer.set_language(Some(rust_lang()), cx); + }); + + cx.update_editor(|editor, _window, cx| { + assert_eq!( + editor.soft_wrap_indent(cx), + language::language_settings::SoftWrapIndent::None + ); + let snapshot = editor.display_snapshot(cx); + assert_eq!(snapshot.soft_wrap_indent(DisplayRow(0)), Some(0)); + }); +} + +#[gpui::test] +async fn test_soft_wrap_indent_updated_when_first_excerpt_changes(cx: &mut gpui::TestAppContext) { + init_test(cx, |settings| { + settings.defaults.soft_wrap = Some(language::language_settings::SoftWrap::Bounded); + settings.defaults.soft_wrap_indent = + Some(language::language_settings::SoftWrapIndent::Same); + settings.defaults.preferred_line_length = Some(20); + settings.languages.0.insert( + "Rust".into(), + LanguageSettingsContent { + soft_wrap_indent: Some(language::language_settings::SoftWrapIndent::None), + ..Default::default() + }, + ); + }); + + let multibuffer = cx.new(|_| MultiBuffer::new(language::Capability::ReadWrite)); + let editor = cx.add_window(|window, cx| build_editor(multibuffer.clone(), window, cx)); + + // Initially empty multibuffer uses global defaults (Same) + editor + .update(cx, |editor, _window, cx| { + assert_eq!( + editor.soft_wrap_indent(cx), + language::language_settings::SoftWrapIndent::Same + ); + }) + .unwrap(); + + let buffer = cx.new(|cx| { + language::Buffer::local(" let a_long_variable = 123456789;\n", cx) + .with_language(rust_lang(), cx) + }); + let (buffer_id, max_point) = + buffer.read_with(cx, |buffer, _cx| (buffer.remote_id(), buffer.max_point())); + + multibuffer.update(cx, |mb, cx| { + mb.set_excerpts_for_buffer(buffer, [Point::new(0, 0)..max_point], 0, cx); + }); + + // Adding the Rust excerpt should update the editor's soft_wrap_indent to None + editor + .update(cx, |editor, _window, cx| { + assert_eq!( + editor.soft_wrap_indent(cx), + language::language_settings::SoftWrapIndent::None + ); + }) + .unwrap(); + + // Continuation line has 0 indent instead of inheriting the 4 spaces from the start of the line + let snapshot = editor + .update(cx, |editor, window, cx| editor.snapshot(window, cx)) + .unwrap(); + assert_eq!(snapshot.soft_wrap_indent(DisplayRow(0)), Some(0)); + + // Removing the excerpt returns the editor to global defaults (Same) + multibuffer.update(cx, |mb, cx| { + mb.remove_excerpts_for_buffer(buffer_id, cx); + }); + + editor + .update(cx, |editor, _window, cx| { + assert_eq!( + editor.soft_wrap_indent(cx), + language::language_settings::SoftWrapIndent::Same + ); + }) + .unwrap(); +} + +#[gpui::test] +async fn test_soft_wrap_indent_updated_on_file_move_between_directories( + cx: &mut gpui::TestAppContext, +) { + init_test(cx, |settings| { + settings.defaults.soft_wrap = Some(language::language_settings::SoftWrap::Bounded); + settings.defaults.preferred_line_length = Some(20); + }); + + let fs = FakeFs::new(cx.executor()); + let root = Path::new("/root"); + fs.insert_tree( + root, + serde_json::json!({ + "dir_a": { + ".zed": { + "settings.json": "{\n \"soft_wrap_indent\": \"same\"\n}" + }, + "test.txt": " let a_long_variable = 123456789;\n" + }, + "dir_b": { + ".zed": { + "settings.json": "{\n \"soft_wrap_indent\": \"none\"\n}" + } + } + }), + ) + .await; + + let project = Project::test(fs.clone(), [root], cx).await; + let worktree = project.update(cx, |project, cx| project.worktrees(cx).next().unwrap()); + let worktree_id = worktree.update(cx, |worktree, _| worktree.id()); + + let buffer = project + .update(cx, |project, cx| { + project.open_buffer((worktree_id, rel_path("dir_a/test.txt")), cx) + }) + .await + .unwrap(); + + let editor = cx.add_window(|window, cx| { + build_editor_with_project( + project.clone(), + MultiBuffer::build_from_buffer(buffer, cx), + window, + cx, + ) + }); + + cx.run_until_parked(); + + // In dir_a, soft_wrap_indent is "same", so continuation row has 4 spaces indent + editor + .update(cx, |editor, _window, cx| { + assert_eq!( + editor.soft_wrap_indent(cx), + language::language_settings::SoftWrapIndent::Same + ); + }) + .unwrap(); + let snapshot = editor + .update(cx, |editor, window, cx| editor.snapshot(window, cx)) + .unwrap(); + assert_eq!(snapshot.soft_wrap_indent(DisplayRow(0)), Some(4)); + + // Move test.txt from dir_a to dir_b + let entry_id = project + .read_with(cx, |project, cx| { + project + .entry_for_path(&(worktree_id, rel_path("dir_a/test.txt")).into(), cx) + .map(|e| e.id) + }) + .unwrap(); + + project + .update(cx, |project, cx| { + project.rename_entry( + entry_id, + (worktree_id, rel_path("dir_b/test.txt")).into(), + cx, + ) + }) + .await + .unwrap(); + + cx.run_until_parked(); + + // After moving to dir_b, soft_wrap_indent should update to "none" + editor + .update(cx, |editor, _window, cx| { + assert_eq!( + editor.soft_wrap_indent(cx), + language::language_settings::SoftWrapIndent::None + ); + }) + .unwrap(); + let snapshot = editor + .update(cx, |editor, window, cx| editor.snapshot(window, cx)) + .unwrap(); + assert_eq!(snapshot.soft_wrap_indent(DisplayRow(0)), Some(0)); +} diff --git a/crates/editor/src/selection.rs b/crates/editor/src/selection.rs new file mode 100644 index 0000000..97ca4b4 --- /dev/null +++ b/crates/editor/src/selection.rs @@ -0,0 +1,2792 @@ +use super::*; +use crate::columnar_selection::ColumnarSelectionRows; + +/// How a [`crate::actions::SelectNext`] or [`crate::actions::SelectPrevious`] +/// action originated. +/// +/// When started from a caret, whole-word matching is always enabled, compared +/// to when started from a selection, where the configured whole-word option is +/// used instead. +enum SelectNextOrigin { + Caret, + Selection, +} + +impl Editor { + pub fn sync_selections( + &mut self, + other: Entity, + cx: &mut Context, + ) -> gpui::Subscription { + self.sync_selections_from(other.read(cx).selections.disjoint_anchors().to_vec(), cx); + + let other_subscription = cx.subscribe(&other, |editor, other, event, cx| { + if let EditorEvent::SelectionsChanged { local: true } = event { + editor.sync_selections_from( + other.read(cx).selections.disjoint_anchors().to_vec(), + cx, + ); + } + }); + + let editor_subscription = cx.subscribe_self::(move |editor, event, cx| { + if let EditorEvent::SelectionsChanged { local: true } = event { + if editor.selections.disjoint_anchors().is_empty() { + return; + } + let selections = editor.selections.disjoint_anchors().to_vec(); + other.update(cx, |other_editor, cx| { + other_editor.sync_selections_from(selections, cx); + }); + } + }); + + Subscription::join(other_subscription, editor_subscription) + } + + /// Changes selections using the provided mutation function. Changes to `self.selections` occur + /// immediately, but when run within `transact` or `with_selection_effects_deferred` other + /// effects of selection change occur at the end of the transaction. + pub fn change_selections( + &mut self, + effects: SelectionEffects, + window: &mut Window, + cx: &mut Context, + change: impl FnOnce(&mut MutableSelectionsCollection<'_, '_>) -> R, + ) -> R { + self.change_selections_with_history(effects, None, window, cx, change) + } + + /// Re-resolves selection anchors so they reference live buffer fragments. + /// + /// Deleted CRDT fragments remain as tombstones. An anchor associated with a + /// tombstone can resolve to the correct visible offset while causing later + /// insertions to be ordered on the wrong side of the cursor. Round-tripping + /// selections through their offsets creates fresh anchors associated with + /// live fragments. + /// + /// This does not emit selection effects, but it replaces pending selection + /// state with the resolved selections. + pub fn refresh_selection_anchors(&mut self, cx: &mut Context) { + let snapshot = self.display_snapshot(cx); + let selections = self.selections.all::(&snapshot); + self.change_selections_without_effects(&snapshot, |selection_collection| { + selection_collection.select(selections); + }); + } + + /// Defers the effects of selection change, so that the effects of multiple calls to + /// `change_selections` are applied at the end. This way these intermediate states aren't added + /// to selection history and the state of popovers based on selection position aren't + /// erroneously updated. + pub fn with_selection_effects_deferred( + &mut self, + window: &mut Window, + cx: &mut Context, + update: impl FnOnce(&mut Self, &mut Window, &mut Context) -> R, + ) -> R { + let already_deferred = self.defer_selection_effects; + self.defer_selection_effects = true; + let result = update(self, window, cx); + self.finish_deferred_selection_effects(already_deferred, window, cx); + result + } + + pub fn has_non_empty_selection(&self, snapshot: &DisplaySnapshot) -> bool { + self.selections + .all_adjusted(snapshot) + .iter() + .any(|selection| !selection.is_empty()) + } + + pub fn is_range_selected(&mut self, range: &Range, cx: &mut Context) -> bool { + if self + .selections + .pending_anchor() + .is_some_and(|pending_selection| { + let snapshot = self.buffer().read(cx).snapshot(cx); + pending_selection.range().includes(range, &snapshot) + }) + { + return true; + } + + self.selections + .disjoint_in_range::(range.clone(), &self.display_snapshot(cx)) + .into_iter() + .any(|selection| { + // This is needed to cover a corner case, if we just check for an existing + // selection in the fold range, having a cursor at the start of the fold + // marks it as selected. Non-empty selections don't cause this. + let length = selection.end - selection.start; + length > 0 + }) + } + + pub fn has_pending_nonempty_selection(&self) -> bool { + let pending_nonempty_selection = match self.selections.pending_anchor() { + Some(Selection { start, end, .. }) => start != end, + None => false, + }; + + pending_nonempty_selection + || (self.columnar_selection_state.is_some() + && self.selections.disjoint_anchors().len() > 1) + } + + pub fn has_pending_selection(&self) -> bool { + self.selections.pending_anchor().is_some() || self.columnar_selection_state.is_some() + } + + pub fn set_selections_from_remote( + &mut self, + selections: Vec>, + pending_selection: Option>, + window: &mut Window, + cx: &mut Context, + ) { + let old_cursor_position = self.selections.newest_anchor().head(); + self.change_selections_without_effects(&self.display_snapshot(cx), |s| { + s.select_anchors(selections); + if let Some(pending_selection) = pending_selection { + s.set_pending(pending_selection, SelectMode::Character); + } else { + s.clear_pending(); + } + }); + self.selections_did_change( + false, + &old_cursor_position, + SelectionEffects::default(), + window, + cx, + ); + } + + pub fn set_mark(&mut self, _: &actions::SetMark, window: &mut Window, cx: &mut Context) { + if self.selection_mark_mode { + self.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.move_with(&mut |_, sel| { + sel.collapse_to(sel.head(), SelectionGoal::None); + }); + }) + } + self.selection_mark_mode = true; + cx.notify(); + } + + pub fn swap_selection_ends( + &mut self, + _: &actions::SwapSelectionEnds, + window: &mut Window, + cx: &mut Context, + ) { + self.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.move_with(&mut |_, sel| { + if sel.start != sel.end { + sel.reversed = !sel.reversed + } + }); + }); + self.request_autoscroll(Autoscroll::newest(), cx); + cx.notify(); + } + + pub fn select_to_end(&mut self, _: &SelectToEnd, window: &mut Window, cx: &mut Context) { + let buffer = self.buffer.read(cx).snapshot(cx); + let mut selection = self + .selections + .first::(&self.display_snapshot(cx)); + selection.set_head(buffer.len(), SelectionGoal::None); + self.change_selections(Default::default(), window, cx, |s| { + s.select(vec![selection]); + }); + } + + pub fn select_all(&mut self, _: &SelectAll, window: &mut Window, cx: &mut Context) { + self.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_ranges(vec![Anchor::Min..Anchor::Max]); + }); + } + + pub fn select_line(&mut self, _: &SelectLine, window: &mut Window, cx: &mut Context) { + let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); + let mut selections = self.selections.all::(&display_map); + let max_point = display_map.buffer_snapshot().max_point(); + for selection in &mut selections { + let rows = selection.spanned_rows(true, &display_map); + selection.start = Point::new(rows.start.0, 0); + selection.end = cmp::min(max_point, Point::new(rows.end.0, 0)); + selection.reversed = false; + } + self.change_selections(Default::default(), window, cx, |s| { + s.select(selections); + }); + } + + pub fn split_selection_into_lines( + &mut self, + action: &SplitSelectionIntoLines, + window: &mut Window, + cx: &mut Context, + ) { + let selections = self + .selections + .all::(&self.display_snapshot(cx)) + .into_iter() + .map(|selection| selection.start..selection.end) + .collect::>(); + self.unfold_ranges(&selections, true, false, cx); + + let mut new_selection_ranges = Vec::new(); + { + let buffer = self.buffer.read(cx).read(cx); + for selection in selections { + for row in selection.start.row..selection.end.row { + let line_start = Point::new(row, 0); + let line_end = Point::new(row, buffer.line_len(MultiBufferRow(row))); + + if action.keep_selections { + // Keep the selection range for each line + let selection_start = if row == selection.start.row { + selection.start + } else { + line_start + }; + new_selection_ranges.push(selection_start..line_end); + } else { + // Collapse to cursor at end of line + new_selection_ranges.push(line_end..line_end); + } + } + + let is_multiline_selection = selection.start.row != selection.end.row; + // Don't insert last one if it's a multi-line selection ending at the start of a line, + // so this action feels more ergonomic when paired with other selection operations + let should_skip_last = is_multiline_selection && selection.end.column == 0; + if !should_skip_last { + if action.keep_selections { + if is_multiline_selection { + let line_start = Point::new(selection.end.row, 0); + new_selection_ranges.push(line_start..selection.end); + } else { + new_selection_ranges.push(selection.start..selection.end); + } + } else { + new_selection_ranges.push(selection.end..selection.end); + } + } + } + } + self.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_ranges(new_selection_ranges); + }); + } + + pub fn add_selection_above( + &mut self, + action: &AddSelectionAbove, + window: &mut Window, + cx: &mut Context, + ) { + self.add_selection(true, action.skip_soft_wrap, window, cx); + } + + pub fn add_selection_below( + &mut self, + action: &AddSelectionBelow, + window: &mut Window, + cx: &mut Context, + ) { + self.add_selection(false, action.skip_soft_wrap, window, cx); + } + + pub fn select_all_matches( + &mut self, + _action: &SelectAllMatches, + window: &mut Window, + cx: &mut Context, + ) -> Result<()> { + let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); + + self.select_next_match_internal(&display_map, false, None, window, cx)?; + let Some(select_next_state) = self.select_next_state.as_mut().filter(|state| !state.done) + else { + return Ok(()); + }; + + let mut new_selections = Vec::new(); + let initial_selection = self.selections.oldest::(&display_map); + let reversed = initial_selection.reversed; + let buffer = display_map.buffer_snapshot(); + let query_matches = select_next_state + .query + .stream_find_iter(buffer.bytes_in_range(MultiBufferOffset(0)..buffer.len())); + + for query_match in query_matches.into_iter() { + let query_match = query_match.context("query match for select all action")?; // can only fail due to I/O + let offset_range = if reversed { + MultiBufferOffset(query_match.end())..MultiBufferOffset(query_match.start()) + } else { + MultiBufferOffset(query_match.start())..MultiBufferOffset(query_match.end()) + }; + + let is_partial_word_match = select_next_state.wordwise + && (buffer.is_inside_word(offset_range.start, None) + || buffer.is_inside_word(offset_range.end, None)); + + let is_initial_selection = MultiBufferOffset(query_match.start()) + == initial_selection.start + && MultiBufferOffset(query_match.end()) == initial_selection.end; + + if !is_partial_word_match && !is_initial_selection { + new_selections.push(offset_range); + } + } + + // Ensure that the initial range is the last selection, as + // `MutableSelectionsCollection::select_ranges` makes the last selection + // the newest selection, which the editor then relies on as the primary + // cursor for scroll targeting. Without this, the last match would then + // be automatically focused when the user started editing the selected + // matches. + let initial_directed_range = if reversed { + initial_selection.end..initial_selection.start + } else { + initial_selection.start..initial_selection.end + }; + new_selections.push(initial_directed_range); + + select_next_state.done = true; + self.unfold_ranges(&new_selections, false, false, cx); + self.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| { + selections.select_ranges(new_selections) + }); + + Ok(()) + } + + pub fn select_next( + &mut self, + action: &SelectNext, + window: &mut Window, + cx: &mut Context, + ) -> Result<()> { + let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); + self.select_next_match_internal( + &display_map, + action.replace_newest, + Some(Autoscroll::newest()), + window, + cx, + ) + } + + pub fn select_previous( + &mut self, + action: &SelectPrevious, + window: &mut Window, + cx: &mut Context, + ) -> Result<()> { + let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); + let buffer = display_map.buffer_snapshot(); + let mut selections = self.selections.all::(&display_map); + if let Some(mut select_prev_state) = self.select_prev_state.take() { + let query = &select_prev_state.query; + if !select_prev_state.done { + let first_selection = selections + .iter() + .min_by_key(|selection| selection.id) + .context("missing selection for select previous action")?; + let last_selection = selections + .iter() + .max_by_key(|selection| selection.id) + .context("missing selection for select previous action")?; + let mut next_selected_range = None; + // When we're iterating matches backwards, the oldest match will actually be the furthest one in the buffer. + let bytes_before_last_selection = + buffer.reversed_bytes_in_range(MultiBufferOffset(0)..last_selection.start); + let bytes_after_first_selection = + buffer.reversed_bytes_in_range(first_selection.end..buffer.len()); + let query_matches = query + .stream_find_iter(bytes_before_last_selection) + .map(|result| (last_selection.start, result)) + .chain( + query + .stream_find_iter(bytes_after_first_selection) + .map(|result| (buffer.len(), result)), + ); + for (end_offset, query_match) in query_matches { + let query_match = + query_match.context("query match for select previous action")?; + let offset_range = + end_offset - query_match.end()..end_offset - query_match.start(); + + if !select_prev_state.wordwise + || (!buffer.is_inside_word(offset_range.start, None) + && !buffer.is_inside_word(offset_range.end, None)) + { + next_selected_range = Some(offset_range); + break; + } + } + + if let Some(next_selected_range) = next_selected_range { + self.select_match_ranges( + next_selected_range, + last_selection.reversed, + action.replace_newest, + Some(Autoscroll::newest()), + window, + cx, + ); + } else { + select_prev_state.done = true; + } + } + + self.select_prev_state = Some(select_prev_state); + } else { + let mut only_carets = true; + let mut same_text_selected = true; + let mut selected_text = None; + + let mut selections_iter = selections.iter().peekable(); + while let Some(selection) = selections_iter.next() { + if selection.start != selection.end { + only_carets = false; + } + + if same_text_selected { + if selected_text.is_none() { + selected_text = + Some(buffer.text_for_range(selection.range()).collect::()); + } + + if let Some(next_selection) = selections_iter.peek() { + if next_selection.len() == selection.len() { + let next_selected_text = buffer + .text_for_range(next_selection.range()) + .collect::(); + if Some(next_selected_text) != selected_text { + same_text_selected = false; + selected_text = None; + } + } else { + same_text_selected = false; + selected_text = None; + } + } + } + } + + if only_carets { + for selection in &mut selections { + let (word_range, _) = buffer.surrounding_word(selection.start, None); + selection.start = word_range.start; + selection.end = word_range.end; + selection.goal = SelectionGoal::None; + selection.reversed = false; + self.select_match_ranges( + selection.start..selection.end, + selection.reversed, + action.replace_newest, + Some(Autoscroll::newest()), + window, + cx, + ); + } + if selections.len() == 1 { + let selection = selections + .last() + .expect("ensured that there's only one selection"); + let query = buffer + .text_for_range(selection.start..selection.end) + .collect::(); + let is_empty = query.is_empty(); + let select_state = self.build_select_state( + query.chars().rev().collect(), + SelectNextOrigin::Caret, + is_empty, + cx, + )?; + self.select_prev_state = Some(select_state); + } else { + self.select_prev_state = None; + } + } else if let Some(selected_text) = selected_text { + self.select_prev_state = Some(self.build_select_state( + selected_text.chars().rev().collect(), + SelectNextOrigin::Selection, + false, + cx, + )?); + self.select_previous(action, window, cx)?; + } + } + Ok(()) + } + + pub fn find_next_match( + &mut self, + _: &FindNextMatch, + window: &mut Window, + cx: &mut Context, + ) -> Result<()> { + let selections = self.selections.disjoint_anchors_arc(); + match selections.first() { + Some(first) if selections.len() >= 2 => { + self.change_selections(Default::default(), window, cx, |s| { + s.select_ranges([first.range()]); + }); + } + _ => self.select_next( + &SelectNext { + replace_newest: true, + }, + window, + cx, + )?, + } + Ok(()) + } + + pub fn find_previous_match( + &mut self, + _: &FindPreviousMatch, + window: &mut Window, + cx: &mut Context, + ) -> Result<()> { + let selections = self.selections.disjoint_anchors_arc(); + match selections.last() { + Some(last) if selections.len() >= 2 => { + self.change_selections(Default::default(), window, cx, |s| { + s.select_ranges([last.range()]); + }); + } + _ => self.select_previous( + &SelectPrevious { + replace_newest: true, + }, + window, + cx, + )?, + } + Ok(()) + } + + pub fn select_enclosing_symbol( + &mut self, + _: &SelectEnclosingSymbol, + window: &mut Window, + cx: &mut Context, + ) { + let buffer = self.buffer.read(cx).snapshot(cx); + let old_selections = self + .selections + .all::(&self.display_snapshot(cx)) + .into_boxed_slice(); + + fn update_selection( + selection: &Selection, + buffer_snap: &MultiBufferSnapshot, + ) -> Option> { + let cursor = selection.head(); + let (_buffer_id, symbols) = buffer_snap.symbols_containing(cursor, None)?; + for symbol in symbols.iter().rev() { + let start = symbol.range.start.to_offset(buffer_snap); + let end = symbol.range.end.to_offset(buffer_snap); + let new_range = start..end; + if start < selection.start || end > selection.end { + return Some(Selection { + id: selection.id, + start: new_range.start, + end: new_range.end, + goal: SelectionGoal::None, + reversed: selection.reversed, + }); + } + } + None + } + + let mut selected_larger_symbol = false; + let new_selections = old_selections + .iter() + .map(|selection| match update_selection(selection, &buffer) { + Some(new_selection) => { + if new_selection.range() != selection.range() { + selected_larger_symbol = true; + } + new_selection + } + None => *selection, + }) + .collect::>(); + + if selected_larger_symbol { + self.change_selections(Default::default(), window, cx, |s| { + s.select(new_selections); + }); + } + } + + pub fn select_larger_syntax_node( + &mut self, + _: &SelectLargerSyntaxNode, + window: &mut Window, + cx: &mut Context, + ) { + let Some(visible_row_count) = self.visible_row_count() else { + return; + }; + let old_selections: Box<[_]> = self + .selections + .all::(&self.display_snapshot(cx)) + .into(); + if old_selections.is_empty() { + return; + } + + let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); + let buffer = self.buffer.read(cx).snapshot(cx); + + let mut selected_larger_node = false; + let mut new_selections = old_selections + .iter() + .map(|selection| { + let old_range = selection.start..selection.end; + + if let Some((node, _)) = buffer.syntax_ancestor(old_range.clone()) { + // manually select word at selection + if ["string_content", "inline"].contains(&node.kind()) { + let (word_range, _) = buffer.surrounding_word(old_range.start, None); + // ignore if word is already selected + if !word_range.is_empty() && old_range != word_range { + let (last_word_range, _) = buffer.surrounding_word(old_range.end, None); + // only select word if start and end point belongs to same word + if word_range == last_word_range { + selected_larger_node = true; + return Selection { + id: selection.id, + start: word_range.start, + end: word_range.end, + goal: SelectionGoal::None, + reversed: selection.reversed, + }; + } + } + } + } + + let mut new_range = old_range.clone(); + while let Some((node, range)) = buffer.syntax_ancestor(new_range.clone()) { + new_range = range; + if !node.is_named() { + continue; + } + if !display_map.intersects_fold(new_range.start) + && !display_map.intersects_fold(new_range.end) + { + break; + } + } + + selected_larger_node |= new_range != old_range; + Selection { + id: selection.id, + start: new_range.start, + end: new_range.end, + goal: SelectionGoal::None, + reversed: selection.reversed, + } + }) + .collect::>(); + + if !selected_larger_node { + return; // don't put this call in the history + } + + // scroll based on transformation done to the last selection created by the user + let (last_old, last_new) = old_selections + .last() + .zip(new_selections.last().cloned()) + .expect("old_selections isn't empty"); + + let is_selection_reversed = if new_selections.len() == 1 { + let should_be_reversed = last_old.start != last_new.start; + new_selections.last_mut().expect("checked above").reversed = should_be_reversed; + should_be_reversed + } else { + last_new.reversed + }; + + self.select_syntax_node_history.disable_clearing = true; + self.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select(new_selections.clone()); + }); + self.select_syntax_node_history.disable_clearing = false; + + let start_row = last_new.start.to_display_point(&display_map).row().0; + let end_row = last_new.end.to_display_point(&display_map).row().0; + let selection_height = end_row - start_row + 1; + let scroll_margin_rows = self.vertical_scroll_margin() as u32; + + let fits_on_the_screen = visible_row_count >= selection_height + scroll_margin_rows * 2; + let scroll_behavior = if fits_on_the_screen { + self.request_autoscroll(Autoscroll::fit(), cx); + SelectSyntaxNodeScrollBehavior::FitSelection + } else if is_selection_reversed { + self.scroll_cursor_top(&ScrollCursorTop, window, cx); + SelectSyntaxNodeScrollBehavior::CursorTop + } else { + self.scroll_cursor_bottom(&ScrollCursorBottom, window, cx); + SelectSyntaxNodeScrollBehavior::CursorBottom + }; + + let old_selections: Box<[Selection]> = old_selections + .iter() + .map(|s| s.map(|offset| buffer.anchor_before(offset))) + .collect(); + self.select_syntax_node_history.push(( + old_selections, + scroll_behavior, + is_selection_reversed, + )); + } + + pub fn select_smaller_syntax_node( + &mut self, + _: &SelectSmallerSyntaxNode, + window: &mut Window, + cx: &mut Context, + ) { + if let Some((mut selections, scroll_behavior, is_selection_reversed)) = + self.select_syntax_node_history.pop() + { + if let Some(selection) = selections.last_mut() { + selection.reversed = is_selection_reversed; + } + + let snapshot = self.buffer.read(cx).snapshot(cx); + let selections: Vec> = selections + .iter() + .map(|s| s.map(|anchor| anchor.to_offset(&snapshot))) + .collect(); + + self.select_syntax_node_history.disable_clearing = true; + self.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select(selections); + }); + self.select_syntax_node_history.disable_clearing = false; + + match scroll_behavior { + SelectSyntaxNodeScrollBehavior::CursorTop => { + self.scroll_cursor_top(&ScrollCursorTop, window, cx); + } + SelectSyntaxNodeScrollBehavior::FitSelection => { + self.request_autoscroll(Autoscroll::fit(), cx); + } + SelectSyntaxNodeScrollBehavior::CursorBottom => { + self.scroll_cursor_bottom(&ScrollCursorBottom, window, cx); + } + } + } + } + + pub fn select_next_syntax_node( + &mut self, + _: &SelectNextSyntaxNode, + window: &mut Window, + cx: &mut Context, + ) { + let old_selections = self.selections.all_anchors(&self.display_snapshot(cx)); + if old_selections.is_empty() { + return; + } + + let buffer = self.buffer.read(cx).snapshot(cx); + let mut selected_sibling = false; + + let new_selections = old_selections + .iter() + .map(|selection| { + let old_range = + selection.start.to_offset(&buffer)..selection.end.to_offset(&buffer); + if let Some(results) = buffer.map_excerpt_ranges( + old_range, + |buf, _excerpt_range, input_buffer_range| { + let Some(node) = buf.syntax_next_sibling(input_buffer_range) else { + return Vec::new(); + }; + vec![( + BufferOffset(node.byte_range().start) + ..BufferOffset(node.byte_range().end), + (), + )] + }, + ) && let [(new_range, _)] = results.as_slice() + { + selected_sibling = true; + let new_range = + buffer.anchor_after(new_range.start)..buffer.anchor_before(new_range.end); + Selection { + id: selection.id, + start: new_range.start, + end: new_range.end, + goal: SelectionGoal::None, + reversed: selection.reversed, + } + } else { + *selection + } + }) + .collect::>(); + + if selected_sibling { + self.change_selections( + SelectionEffects::scroll(Autoscroll::fit()), + window, + cx, + |s| { + s.select(new_selections); + }, + ); + } + } + + pub fn select_prev_syntax_node( + &mut self, + _: &SelectPreviousSyntaxNode, + window: &mut Window, + cx: &mut Context, + ) { + let old_selections: Arc<[_]> = self.selections.all_anchors(&self.display_snapshot(cx)); + + let multibuffer_snapshot = self.buffer.read(cx).snapshot(cx); + let mut selected_sibling = false; + + let new_selections = old_selections + .iter() + .map(|selection| { + let old_range = selection.start.to_offset(&multibuffer_snapshot) + ..selection.end.to_offset(&multibuffer_snapshot); + if let Some(results) = multibuffer_snapshot.map_excerpt_ranges( + old_range, + |buf, _excerpt_range, input_buffer_range| { + let Some(node) = buf.syntax_prev_sibling(input_buffer_range) else { + return Vec::new(); + }; + vec![( + BufferOffset(node.byte_range().start) + ..BufferOffset(node.byte_range().end), + (), + )] + }, + ) && let [(new_range, _)] = results.as_slice() + { + selected_sibling = true; + let new_range = multibuffer_snapshot.anchor_after(new_range.start) + ..multibuffer_snapshot.anchor_before(new_range.end); + Selection { + id: selection.id, + start: new_range.start, + end: new_range.end, + goal: SelectionGoal::None, + reversed: selection.reversed, + } + } else { + *selection + } + }) + .collect::>(); + + if selected_sibling { + self.change_selections( + SelectionEffects::scroll(Autoscroll::fit()), + window, + cx, + |s| { + s.select(new_selections); + }, + ); + } + } + + pub fn move_to_start_of_larger_syntax_node( + &mut self, + _: &MoveToStartOfLargerSyntaxNode, + window: &mut Window, + cx: &mut Context, + ) { + self.move_cursors_to_syntax_nodes(window, cx, false); + } + + pub fn move_to_end_of_larger_syntax_node( + &mut self, + _: &MoveToEndOfLargerSyntaxNode, + window: &mut Window, + cx: &mut Context, + ) { + self.move_cursors_to_syntax_nodes(window, cx, true); + } + + pub fn select_to_start_of_larger_syntax_node( + &mut self, + _: &SelectToStartOfLargerSyntaxNode, + window: &mut Window, + cx: &mut Context, + ) { + self.select_to_syntax_nodes(window, cx, false); + } + + pub fn select_to_end_of_larger_syntax_node( + &mut self, + _: &SelectToEndOfLargerSyntaxNode, + window: &mut Window, + cx: &mut Context, + ) { + self.select_to_syntax_nodes(window, cx, true); + } + + pub fn select_inside_delimiters( + &mut self, + _: &SelectInsideDelimiters, + window: &mut Window, + cx: &mut Context, + ) { + self.select_delimiters_impl(false, window, cx); + } + + pub fn select_around_delimiters( + &mut self, + _: &SelectAroundDelimiters, + window: &mut Window, + cx: &mut Context, + ) { + self.select_delimiters_impl(true, window, cx); + } + + fn select_delimiters_impl( + &mut self, + include_brackets: bool, + window: &mut Window, + cx: &mut Context, + ) { + self.change_selections(Default::default(), window, cx, |s| { + s.move_offsets_with(&mut |snapshot, selection| { + let Some(enclosing_bracket_ranges) = + snapshot.enclosing_bracket_ranges(selection.start..selection.end) + else { + return; + }; + + let mut best = None; + let mut best_length = usize::MAX; + + for (open, close) in enclosing_bracket_ranges { + let range = if include_brackets { + open.start..close.end + } else { + open.end..close.start + }; + + // Skip any bracket pair that is already covered by the + // selection so repeated uses of delimiters selection only + // evere expands outwards to the next pair. + if (selection.start..selection.end).contains_inclusive(&range) { + continue; + } + + let length = close.end - open.start; + if length < best_length { + best_length = length; + best = Some(range); + } + } + + if let Some(range) = best { + selection.set_head_tail(range.end, range.start, SelectionGoal::None); + } + }) + }); + } + + pub fn move_to_enclosing_bracket( + &mut self, + _: &MoveToEnclosingBracket, + window: &mut Window, + cx: &mut Context, + ) { + self.change_selections(Default::default(), window, cx, |s| { + s.move_offsets_with(&mut |snapshot, selection| { + let Some(enclosing_bracket_ranges) = + snapshot.enclosing_bracket_ranges(selection.start..selection.end) + else { + return; + }; + + let mut best_length = usize::MAX; + let mut best_inside = false; + let mut best_in_bracket_range = false; + let mut best_destination = None; + for (open, close) in enclosing_bracket_ranges { + let close = close.to_inclusive(); + let length = *close.end() - open.start; + let inside = selection.start >= open.end && selection.end <= *close.start(); + let in_bracket_range = open.to_inclusive().contains(&selection.head()) + || close.contains(&selection.head()); + + // If best is next to a bracket and current isn't, skip + if !in_bracket_range && best_in_bracket_range { + continue; + } + + // Prefer smaller lengths unless best is inside and current isn't + if length > best_length && (best_inside || !inside) { + continue; + } + + best_length = length; + best_inside = inside; + best_in_bracket_range = in_bracket_range; + best_destination = Some( + if close.contains(&selection.start) && close.contains(&selection.end) { + if inside { open.end } else { open.start } + } else if inside { + *close.start() + } else { + *close.end() + }, + ); + } + + if let Some(destination) = best_destination { + selection.collapse_to(destination, SelectionGoal::None); + } + }) + }); + } + + pub fn undo_selection( + &mut self, + _: &UndoSelection, + window: &mut Window, + cx: &mut Context, + ) { + if let Some(entry) = self.selection_history.undo_stack.pop_back() { + self.selection_history.mode = SelectionHistoryMode::Undoing; + self.with_selection_effects_deferred(window, cx, |this, window, cx| { + this.end_selection(window, cx); + this.change_selections( + SelectionEffects::scroll(Autoscroll::newest()), + window, + cx, + |s| s.select_anchors_unexpanded(entry.selections.to_vec()), + ); + }); + self.selection_history.mode = SelectionHistoryMode::Normal; + + self.select_next_state = entry.select_next_state; + self.select_prev_state = entry.select_prev_state; + self.add_selections_state = entry.add_selections_state; + } + } + + pub fn redo_selection( + &mut self, + _: &RedoSelection, + window: &mut Window, + cx: &mut Context, + ) { + if let Some(entry) = self.selection_history.redo_stack.pop_back() { + self.selection_history.mode = SelectionHistoryMode::Redoing; + self.with_selection_effects_deferred(window, cx, |this, window, cx| { + this.end_selection(window, cx); + this.change_selections( + SelectionEffects::scroll(Autoscroll::newest()), + window, + cx, + |s| s.select_anchors_unexpanded(entry.selections.to_vec()), + ); + }); + self.selection_history.mode = SelectionHistoryMode::Normal; + + self.select_next_state = entry.select_next_state; + self.select_prev_state = entry.select_prev_state; + self.add_selections_state = entry.add_selections_state; + } + } + + pub(super) fn select( + &mut self, + phase: SelectPhase, + window: &mut Window, + cx: &mut Context, + ) { + self.hide_context_menu(window, cx); + + match phase { + SelectPhase::Begin { + position, + add, + click_count, + } => self.begin_selection(position, add, click_count, window, cx), + SelectPhase::BeginColumnar { + position, + goal_column, + reset, + mode, + } => self.begin_columnar_selection(position, goal_column, reset, mode, window, cx), + SelectPhase::Extend { + position, + click_count, + } => self.extend_selection(position, click_count, window, cx), + SelectPhase::Update { + position, + goal_column, + scroll_delta, + } => self.update_selection(position, goal_column, scroll_delta, window, cx), + SelectPhase::End => self.end_selection(window, cx), + } + } + + pub(super) fn extend_selection( + &mut self, + position: DisplayPoint, + click_count: usize, + window: &mut Window, + cx: &mut Context, + ) { + let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); + let tail = self + .selections + .newest::(&display_map) + .tail(); + let click_count = click_count.max(match self.selections.select_mode() { + SelectMode::Character => 1, + SelectMode::Word(_) => 2, + SelectMode::Line(_) => 3, + SelectMode::All => 4, + }); + self.begin_selection(position, false, click_count, window, cx); + + let tail_anchor = display_map.buffer_snapshot().anchor_before(tail); + + let current_selection = match self.selections.select_mode() { + SelectMode::Character | SelectMode::All => tail_anchor..tail_anchor, + SelectMode::Word(range) | SelectMode::Line(range) => range.clone(), + }; + + let Some((mut pending_selection, mut pending_mode)) = self.pending_selection_and_mode() + else { + log::error!("extend_selection dispatched with no pending selection"); + return; + }; + + if pending_selection + .start + .cmp(¤t_selection.start, display_map.buffer_snapshot()) + == Ordering::Greater + { + pending_selection.start = current_selection.start; + } + if pending_selection + .end + .cmp(¤t_selection.end, display_map.buffer_snapshot()) + == Ordering::Less + { + pending_selection.end = current_selection.end; + pending_selection.reversed = true; + } + + match &mut pending_mode { + SelectMode::Word(range) | SelectMode::Line(range) => *range = current_selection, + _ => {} + } + + let effects = if EditorSettings::get_global(cx).autoscroll_on_clicks { + SelectionEffects::scroll(Autoscroll::fit()) + } else { + SelectionEffects::no_scroll() + }; + + self.change_selections(effects, window, cx, |s| { + s.set_pending(pending_selection, pending_mode); + s.set_is_extending(true); + }); + } + + pub(super) fn begin_selection( + &mut self, + position: DisplayPoint, + add: bool, + click_count: usize, + window: &mut Window, + cx: &mut Context, + ) { + if !self.focus_handle.is_focused(window) { + self.last_focused_descendant = None; + window.focus(&self.focus_handle, cx); + } + + let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); + let buffer = display_map.buffer_snapshot(); + let position = display_map.clip_point(position, Bias::Left); + + let start; + let end; + let mode; + let mut auto_scroll; + match click_count { + 1 => { + start = buffer.anchor_before(position.to_point(&display_map)); + end = start; + mode = SelectMode::Character; + auto_scroll = true; + } + 2 => { + let position = display_map + .clip_point(position, Bias::Left) + .to_offset(&display_map, Bias::Left); + let (range, _) = buffer.surrounding_word(position, None); + start = buffer.anchor_before(range.start); + end = buffer.anchor_before(range.end); + mode = SelectMode::Word(start..end); + auto_scroll = true; + } + 3 => { + let position = display_map + .clip_point(position, Bias::Left) + .to_point(&display_map); + let line_start = display_map.prev_line_boundary(position).0; + let next_line_start = buffer.clip_point( + display_map.next_line_boundary(position).0 + Point::new(1, 0), + Bias::Left, + ); + start = buffer.anchor_before(line_start); + end = buffer.anchor_before(next_line_start); + mode = SelectMode::Line(start..end); + auto_scroll = true; + } + _ => { + start = buffer.anchor_before(MultiBufferOffset(0)); + end = buffer.anchor_before(buffer.len()); + mode = SelectMode::All; + auto_scroll = false; + } + } + auto_scroll &= EditorSettings::get_global(cx).autoscroll_on_clicks; + + let point_to_delete: Option = { + let selected_points: Vec> = + self.selections.disjoint_in_range(start..end, &display_map); + + if !add || click_count > 1 { + None + } else if !selected_points.is_empty() { + Some(selected_points[0].id) + } else { + let clicked_point_already_selected = + self.selections.disjoint_anchors().iter().find(|selection| { + selection.start.to_point(buffer) == start.to_point(buffer) + || selection.end.to_point(buffer) == end.to_point(buffer) + }); + + clicked_point_already_selected.map(|selection| selection.id) + } + }; + + let selections_count = self.selections.count(); + let effects = if auto_scroll { + SelectionEffects::default() + } else { + SelectionEffects::no_scroll() + }; + + self.change_selections(effects, window, cx, |s| { + if let Some(point_to_delete) = point_to_delete { + s.delete(point_to_delete); + + if selections_count == 1 { + s.set_pending_anchor_range(start..end, mode); + } + } else { + if !add { + s.clear_disjoint(); + } + + s.set_pending_anchor_range(start..end, mode); + } + }); + } + + pub(super) fn update_selection( + &mut self, + position: DisplayPoint, + goal_column: u32, + scroll_delta: gpui::Point, + window: &mut Window, + cx: &mut Context, + ) { + let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); + + if self.columnar_selection_state.is_some() { + self.select_columns(position, goal_column, &display_map, window, cx); + } else if let Some((mut pending, mode)) = self.pending_selection_and_mode() { + let buffer = display_map.buffer_snapshot(); + let head; + let tail; + match &mode { + SelectMode::Character => { + head = position.to_point(&display_map); + tail = pending.tail().to_point(buffer); + } + SelectMode::Word(original_range) => { + let offset = display_map + .clip_point(position, Bias::Left) + .to_offset(&display_map, Bias::Left); + let original_range = original_range.to_offset(buffer); + + let head_offset = if buffer.is_inside_word(offset, None) + || original_range.contains(&offset) + { + let (word_range, _) = buffer.surrounding_word(offset, None); + if word_range.start < original_range.start { + word_range.start + } else { + word_range.end + } + } else { + offset + }; + + head = head_offset.to_point(buffer); + if head_offset <= original_range.start { + tail = original_range.end.to_point(buffer); + } else { + tail = original_range.start.to_point(buffer); + } + } + SelectMode::Line(original_range) => { + let original_range = original_range.to_point(display_map.buffer_snapshot()); + + let position = display_map + .clip_point(position, Bias::Left) + .to_point(&display_map); + let line_start = display_map.prev_line_boundary(position).0; + let next_line_start = buffer.clip_point( + display_map.next_line_boundary(position).0 + Point::new(1, 0), + Bias::Left, + ); + + if line_start < original_range.start { + head = line_start + } else { + head = next_line_start + } + + if head <= original_range.start { + tail = original_range.end; + } else { + tail = original_range.start; + } + } + SelectMode::All => { + return; + } + }; + + if head < tail { + pending.start = buffer.anchor_before(head); + pending.end = buffer.anchor_before(tail); + pending.reversed = true; + } else { + pending.start = buffer.anchor_before(tail); + pending.end = buffer.anchor_before(head); + pending.reversed = false; + } + + self.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.set_pending(pending, mode); + }); + } else { + log::error!("update_selection dispatched with no pending selection"); + return; + } + + self.apply_scroll_delta(scroll_delta, window, cx); + cx.notify(); + } + + pub(super) fn end_selection(&mut self, window: &mut Window, cx: &mut Context) { + self.columnar_selection_state.take(); + if let Some(pending_mode) = self.selections.pending_mode() { + let selections = self + .selections + .all::(&self.display_snapshot(cx)); + // `select` below resets the selections' granularity to `Character`, since it's the + // funnel every wholesale selection replacement goes through. When extending, the + // granularity established by the selection gesture that started the extension must + // survive that reset instead of being overwritten by `pending_mode`. + let select_mode = if self.selections.is_extending() { + self.selections.select_mode().clone() + } else { + pending_mode + }; + self.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select(selections); + s.clear_pending(); + s.set_is_extending(false); + s.set_select_mode(select_mode); + }); + } + } + + fn begin_columnar_selection( + &mut self, + position: DisplayPoint, + goal_column: u32, + reset: bool, + mode: ColumnarMode, + window: &mut Window, + cx: &mut Context, + ) { + if !self.focus_handle.is_focused(window) { + self.last_focused_descendant = None; + window.focus(&self.focus_handle, cx); + } + + let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); + + if reset { + let pointer_position = display_map + .buffer_snapshot() + .anchor_before(position.to_point(&display_map)); + + self.change_selections( + SelectionEffects::scroll(Autoscroll::newest()), + window, + cx, + |s| { + s.clear_disjoint(); + s.set_pending_anchor_range( + pointer_position..pointer_position, + SelectMode::Character, + ); + }, + ); + }; + + let tail = self.selections.newest::(&display_map).tail(); + let selection_anchor = display_map.buffer_snapshot().anchor_before(tail); + self.columnar_selection_state = match mode { + ColumnarMode::FromMouse => Some(ColumnarSelectionState::FromMouse { + selection_tail: selection_anchor, + display_point: if reset { + if position.column() != goal_column { + Some(DisplayPoint::new(position.row(), goal_column)) + } else { + None + } + } else { + None + }, + }), + ColumnarMode::FromSelection => Some(ColumnarSelectionState::FromSelection { + selection_tail: selection_anchor, + }), + }; + + if !reset { + self.select_columns(position, goal_column, &display_map, window, cx); + } + } + + /// Re-publishes the current selections to collaborators immediately. The + /// per-change broadcast in `selections_did_change` is skipped while the + /// project is unshared, so when a project becomes shared this is called to + /// re-establish this editor's cursor for peers, who would otherwise not see + /// it until the next selection change. + pub(crate) fn republish_active_selections( + &mut self, + window: &mut Window, + cx: &mut Context, + ) { + let should_broadcast_selections = self + .collaboration_hub() + .is_some_and(|hub| hub.should_broadcast_selections(cx)); + if should_broadcast_selections + && self.focus_handle.is_focused(window) + && self.leader_id.is_none() + { + self.buffer.update(cx, |buffer, cx| { + buffer.set_active_selections( + &self.selections.disjoint_anchors_arc(), + self.selections.line_mode(), + self.cursor_shape, + cx, + ) + }); + } + } + + fn selections_did_change( + &mut self, + local: bool, + old_cursor_position: &Anchor, + effects: SelectionEffects, + window: &mut Window, + cx: &mut Context, + ) { + self.last_selection_from_search = effects.from_search; + window.invalidate_character_coordinates(); + + // Copy selections to primary selection buffer + #[cfg(any(target_os = "linux", target_os = "freebsd"))] + if local { + let selections = self + .selections + .all::(&self.display_snapshot(cx)); + let buffer_handle = self.buffer.read(cx).read(cx); + + let mut text = String::new(); + for (index, selection) in selections.iter().enumerate() { + let text_for_selection = buffer_handle + .text_for_range(selection.start..selection.end) + .collect::(); + + text.push_str(&text_for_selection); + if index != selections.len() - 1 { + text.push('\n'); + } + } + + if !text.is_empty() { + cx.write_to_primary(ClipboardItem::new_string(text)); + } + } + + let selection_anchors = self.selections.disjoint_anchors_arc(); + + let should_broadcast_selections = self + .collaboration_hub() + .is_some_and(|hub| hub.should_broadcast_selections(cx)); + if should_broadcast_selections + && self.focus_handle.is_focused(window) + && self.leader_id.is_none() + { + self.buffer.update(cx, |buffer, cx| { + buffer.set_active_selections( + &selection_anchors, + self.selections.line_mode(), + self.cursor_shape, + cx, + ) + }); + } + let display_map = self + .display_map + .update(cx, |display_map, cx| display_map.snapshot(cx)); + let buffer = display_map.buffer_snapshot(); + self.select_next_state = None; + self.select_prev_state = None; + self.select_syntax_node_history.try_clear(); + self.invalidate_autoclose_regions(&selection_anchors, buffer); + self.snippet_stack.invalidate(&selection_anchors, buffer); + self.take_rename(false, window, cx); + self.take_inline_input(window, cx); + + let newest_selection = self.selections.newest_anchor(); + let new_cursor_position = newest_selection.head(); + let selection_start = newest_selection.start; + + if effects.nav_history.is_none() || effects.nav_history == Some(true) { + self.push_to_nav_history( + *old_cursor_position, + Some(new_cursor_position.to_point(buffer)), + false, + effects.nav_history == Some(true), + cx, + ); + } + + if local { + if let Some((anchor, _)) = buffer.anchor_to_buffer_anchor(new_cursor_position) { + self.register_buffer(anchor.buffer_id, cx); + } + + let mut context_menu = self.context_menu.borrow_mut(); + let completion_menu = match context_menu.as_ref() { + Some(CodeContextMenu::Completions(menu)) => Some(menu), + Some(CodeContextMenu::CodeActions(_)) => { + *context_menu = None; + None + } + None => None, + }; + let completion_position = completion_menu.map(|menu| menu.initial_position); + drop(context_menu); + + if effects.completions + && let Some(completion_position) = completion_position + { + let start_offset = selection_start.to_offset(buffer); + let position_matches = start_offset == completion_position.to_offset(buffer); + let continue_showing = if let Some((snap, ..)) = + buffer.point_to_buffer_offset(completion_position) + && !snap.capability.editable() + { + false + } else if position_matches { + if self.snippet_stack.is_empty() { + buffer.char_kind_before(start_offset, Some(CharScopeContext::Completion)) + == Some(CharKind::Word) + } else { + // Snippet choices can be shown even when the cursor is in whitespace. + // Dismissing the menu with actions like backspace is handled by + // invalidation regions. + true + } + } else { + false + }; + + if continue_showing { + self.open_or_update_completions_menu(None, None, false, window, cx); + } else { + self.hide_context_menu(window, cx); + } + } + + hide_hover(self, cx); + + self.refresh_code_actions_for_selection(window, cx); + self.refresh_document_highlights(cx); + refresh_linked_ranges(self, window, cx); + + self.refresh_selected_text_highlights(&display_map, false, window, cx); + self.refresh_matching_bracket_highlights(&display_map, cx); + self.refresh_outline_symbols_at_cursor(cx); + self.update_visible_edit_prediction(window, cx); + self.hide_blame_popover(true, cx); + if self.git_blame_inline_enabled { + self.start_inline_blame_timer(window, cx); + } + } + + self.blink_manager.update(cx, BlinkManager::pause_blinking); + + if local && !self.suppress_selection_callback { + if let Some(callback) = self.on_local_selections_changed.as_ref() { + let cursor_position = self.selections.newest::(&display_map).head(); + callback(cursor_position, window, cx); + } + } + + cx.emit(EditorEvent::SelectionsChanged { local }); + + let selections = &self.selections.disjoint_anchors_arc(); + if local && let Some(buffer_snapshot) = buffer.as_singleton() { + let inmemory_selections = selections + .iter() + .map(|s| { + let start = s.range().start.text_anchor_in(buffer_snapshot); + let end = s.range().end.text_anchor_in(buffer_snapshot); + (start..end).to_point(buffer_snapshot) + }) + .collect(); + self.update_restoration_data(cx, |data| { + data.selections = inmemory_selections; + }); + + if WorkspaceSettings::get(None, cx).restore_on_startup + != RestoreOnStartupBehavior::EmptyTab + && let Some(workspace_id) = self.workspace_serialization_id(cx) + { + let snapshot = self.buffer().read(cx).snapshot(cx); + let selections = selections.clone(); + let background_executor = cx.background_executor().clone(); + let editor_id = cx.entity().entity_id().as_u64() as ItemId; + let db = EditorDb::global(cx); + self.serialize_selections = cx.background_spawn(async move { + background_executor.timer(SERIALIZATION_THROTTLE_TIME).await; + let db_selections = selections + .iter() + .map(|selection| { + ( + selection.start.to_offset(&snapshot).0, + selection.end.to_offset(&snapshot).0, + ) + }) + .collect(); + + db.save_editor_selections(editor_id, workspace_id, db_selections) + .await + .with_context(|| { + format!( + "persisting editor selections for editor {editor_id}, \ + workspace {workspace_id:?}" + ) + }) + .log_err(); + }); + } + } + + cx.notify(); + } + + #[inline(never)] + fn prepare_selection_effects( + &self, + effects: SelectionEffects, + history_entry: Option, + ) -> DeferredSelectionEffectsState { + DeferredSelectionEffectsState { + changed: false, + effects, + old_cursor_position: self.selections.newest_anchor().head(), + history_entry: history_entry.unwrap_or_else(|| self.selection_history_entry()), + } + } + + #[inline(never)] + fn finish_deferred_selection_effects( + &mut self, + already_deferred: bool, + window: &mut Window, + cx: &mut Context, + ) { + if !already_deferred { + self.defer_selection_effects = false; + if let Some(state) = self.deferred_selection_effects_state.take() { + self.apply_selection_effects(state, window, cx); + } + } + } + + #[inline(never)] + fn apply_selection_effects( + &mut self, + state: DeferredSelectionEffectsState, + window: &mut Window, + cx: &mut Context, + ) { + if self.defer_selection_effects { + self.deferred_selection_effects_state = Some(state); + return; + } + + if state.changed { + self.selection_history.push(state.history_entry); + + if let Some(autoscroll) = state.effects.scroll { + self.request_autoscroll(autoscroll, cx); + } + + let old_cursor_position = &state.old_cursor_position; + + self.selections_did_change(true, old_cursor_position, state.effects, window, cx); + + if self.should_open_signature_help_automatically(old_cursor_position, cx) { + self.show_signature_help_auto(window, cx); + } + } + } + + fn select_columns( + &mut self, + head: DisplayPoint, + goal_column: u32, + display_map: &DisplaySnapshot, + window: &mut Window, + cx: &mut Context, + ) { + let Some(columnar_state) = self.columnar_selection_state.as_ref() else { + return; + }; + + let tail = match columnar_state { + ColumnarSelectionState::FromMouse { + selection_tail, + display_point, + } => display_point.unwrap_or_else(|| selection_tail.to_display_point(display_map)), + ColumnarSelectionState::FromSelection { selection_tail } => { + selection_tail.to_display_point(display_map) + } + }; + + let start_row = cmp::min(tail.row(), head.row()); + let end_row = cmp::max(tail.row(), head.row()); + + // Anchor the columnar rectangle in x pixels rather than byte columns so + // rows with multi-byte characters (e.g. diacritics) stay visually aligned. + let text_layout_details = self.text_layout_details(window, cx); + + // The mouse handlers encode drags past a line's end as extra columns + // beyond the line length, in em layout widths (see + // `PositionMap::point_for_position`). `x_for_display_point` clamps at + // the line's width, so convert that overshoot back to pixels with the + // same unit to keep the rectangle tracking the mouse past short lines. + let font_id = text_layout_details + .text_system + .resolve_font(&text_layout_details.editor_style.text.font()); + let font_size = text_layout_details + .editor_style + .text + .font_size + .to_pixels(text_layout_details.rem_size); + let em_layout_width = text_layout_details + .text_system + .em_layout_width(font_id, font_size); + let x_for_unclipped_point = |point: DisplayPoint| { + let line_len = display_map.line_len(point.row()); + if point.column() > line_len { + let eol_x = display_map.x_for_display_point( + DisplayPoint::new(point.row(), line_len), + &text_layout_details, + ); + eol_x + em_layout_width * (point.column() - line_len) as f32 + } else { + display_map.x_for_display_point(point, &text_layout_details) + } + }; + + let tail_x = x_for_unclipped_point(tail); + let head_x = x_for_unclipped_point(DisplayPoint::new(head.row(), goal_column)); + let start_x = tail_x.min(head_x); + let end_x = tail_x.max(head_x); + let reversed = head_x < tail_x; + + let mut last_buffer_row = None; + let selection_ranges = (start_row.0..=end_row.0) + .map(DisplayRow) + .filter_map(|row| { + if display_map.is_block_line(row) { + return None; + } + + let buffer_row = row.as_display_point().to_point(display_map).row; + if last_buffer_row == Some(buffer_row) { + return None; + } + last_buffer_row = Some(buffer_row); + + let layout = display_map.layout_row(row, &text_layout_details); + if matches!(columnar_state, ColumnarSelectionState::FromSelection { .. }) + && start_x > layout.width + { + return None; + } + + let start_column = layout.closest_index_for_x(start_x) as u32; + let end_column = layout.closest_index_for_x(end_x) as u32; + + let start = display_map + .clip_point(DisplayPoint::new(row, start_column), Bias::Left) + .to_point(display_map); + let end = display_map + .clip_point(DisplayPoint::new(row, end_column), Bias::Right) + .to_point(display_map); + if reversed { + Some(end..start) + } else { + Some(start..end) + } + }) + .collect::>(); + if selection_ranges.is_empty() { + return; + } + + let ranges = match columnar_state { + ColumnarSelectionState::FromMouse { .. } => { + let mut non_empty_ranges = selection_ranges + .iter() + .filter(|selection_range| selection_range.start != selection_range.end) + .peekable(); + if non_empty_ranges.peek().is_some() { + non_empty_ranges.cloned().collect() + } else { + selection_ranges + } + } + _ => selection_ranges, + }; + + self.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_ranges(ranges); + }); + cx.notify(); + } + + fn pending_selection_and_mode(&self) -> Option<(Selection, SelectMode)> { + Some(( + *self.selections.pending_anchor()?, + self.selections.pending_mode()?, + )) + } + + pub(super) fn invalidate_add_selection_goals_after_change( + &mut self, + previous: Option<&[Selection]>, + ) { + if self.add_selections_state.is_none() { + return; + } + let Some(previous) = previous else { + self.add_selections_state = None; + return; + }; + let current = self + .selections + .disjoint_anchors() + .iter() + .map(|selection| (selection.id, selection)) + .collect::>(); + let unchanged = previous + .iter() + .filter(|selection| current.get(&selection.id) == Some(selection)) + .map(|selection| selection.id) + .collect::>(); + let removed = previous + .iter() + .filter(|selection| !current.contains_key(&selection.id)) + .map(|selection| selection.id) + .collect::>(); + if let Some(state) = &mut self.add_selections_state { + Self::retain_add_selection_goals(state, &unchanged, &removed, ¤t); + if state.groups.is_empty() { + self.add_selections_state = None; + } + } + } + + fn sync_selections_from(&mut self, selections: Vec>, cx: &mut Context) { + if !selections.is_empty() { + self.change_selections_without_effects(&self.display_snapshot(cx), |collection| { + collection.select_anchors(selections); + }); + } + } + + fn retain_add_selection_goals( + state: &mut AddSelectionsState, + unchanged: &HashSet, + removed: &HashSet, + current: &HashMap>, + ) { + let skip_soft_wrap = state.skip_soft_wrap; + state.groups.retain_mut(|group| { + if group.stack.first().is_none_or(|id| !unchanged.contains(id)) + || group + .stack + .iter() + .any(|id| !unchanged.contains(id) && !removed.contains(id)) + { + group.goal_source = None; + } + group.stack.retain(|id| current.contains_key(id)); + !group.stack.is_empty() && (skip_soft_wrap || group.stack.len() > 1) + }); + } + + fn reconcile_add_selection_groups( + state: &mut AddSelectionsState, + previous: &[Selection], + current: &[Selection], + ) { + let current_by_id = current + .iter() + .map(|selection| (selection.id, selection)) + .collect::>(); + let mut unchanged = HashSet::default(); + let mut removed = HashSet::default(); + for selection in previous { + if let Some(current) = current_by_id.get(&selection.id) { + if **current == *selection { + unchanged.insert(selection.id); + } + } else { + let index = current.partition_point(|current| current.end < selection.start); + let overlaps = current + .iter() + .skip(index) + .take_while(|current| current.start <= selection.end) + .any(|current| { + if current.is_empty() || selection.is_empty() { + current.start <= selection.end && selection.start <= current.end + } else { + current.start < selection.end && selection.start < current.end + } + }); + if !overlaps { + removed.insert(selection.id); + } + } + } + Self::retain_add_selection_goals(state, &unchanged, &removed, ¤t_by_id); + } + + fn resolve_add_selection_anchors( + selections: &[Selection], + snapshot: &DisplaySnapshot, + ) -> Vec> { + selections + .iter() + .filter(|selection| { + snapshot.can_resolve(&selection.start) && snapshot.can_resolve(&selection.end) + }) + .map(|selection| selection.map(|anchor| anchor.to_point(snapshot.buffer_snapshot()))) + .collect() + } + + fn change_selections_without_effects( + &mut self, + snapshot: &DisplaySnapshot, + change: impl FnOnce(&mut MutableSelectionsCollection<'_, '_>) -> R, + ) -> (bool, R) { + let previous = self + .add_selections_state + .as_ref() + .map(|_| self.selections.disjoint_anchors_arc()); + let (changed, result) = self.selections.change_with(snapshot, change); + if changed + && let Some(previous) = previous + && let Some(state) = &mut self.add_selections_state + { + let previous = Self::resolve_add_selection_anchors(&previous, snapshot); + let current = + Self::resolve_add_selection_anchors(self.selections.disjoint_anchors(), snapshot); + Self::reconcile_add_selection_groups(state, &previous, ¤t); + if state.groups.is_empty() { + self.add_selections_state = None; + } + } + (changed, result) + } + + fn selection_history_entry(&self) -> SelectionHistoryEntry { + SelectionHistoryEntry { + selections: self.selections.disjoint_anchors_arc(), + select_next_state: self.select_next_state.clone(), + select_prev_state: self.select_prev_state.clone(), + add_selections_state: self.add_selections_state.clone(), + } + } + + fn change_selections_with_history( + &mut self, + effects: SelectionEffects, + history_entry: Option, + window: &mut Window, + cx: &mut Context, + change: impl FnOnce(&mut MutableSelectionsCollection<'_, '_>) -> R, + ) -> R { + let snapshot = self.display_snapshot(cx); + if self.deferred_selection_effects_state.is_some() { + let (changed, result) = self.change_selections_without_effects(&snapshot, change); + if let Some(state) = &mut self.deferred_selection_effects_state { + state.effects.scroll = effects.scroll.or(state.effects.scroll); + state.effects.completions = effects.completions; + state.effects.nav_history = effects.nav_history.or(state.effects.nav_history); + state.changed |= changed; + } + return result; + } + let mut state = self.prepare_selection_effects(effects, history_entry); + let (changed, result) = self.change_selections_without_effects(&snapshot, change); + state.changed = state.changed || changed; + self.apply_selection_effects(state, window, cx); + result + } + + fn add_selection( + &mut self, + above: bool, + skip_soft_wrap: bool, + window: &mut Window, + cx: &mut Context, + ) { + let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); + let text_layout_details = self.text_layout_details(window, cx); + let buffer = display_map.buffer_snapshot(); + let all_selections = self.selections.all_unexpanded::(&display_map); + let goal_source_for_selection = |selection: &Selection| { + let end_bias = if selection.is_empty() { + Bias::Right + } else { + Bias::Left + }; + buffer.anchor_after(selection.start)..buffer.anchor_at(selection.end, end_bias) + }; + + let history_entry = self + .deferred_selection_effects_state + .is_none() + .then(|| self.selection_history_entry()); + if self + .add_selections_state + .as_ref() + .is_some_and(|state| state.skip_soft_wrap != skip_soft_wrap) + { + self.add_selections_state = None; + } + let mut state = self + .add_selections_state + .take() + .unwrap_or_else(|| AddSelectionsState { + groups: Vec::new(), + skip_soft_wrap, + }); + if !state.groups.is_empty() { + let previous = Self::resolve_add_selection_anchors( + self.selections.disjoint_anchors(), + &display_map, + ); + Self::reconcile_add_selection_groups(&mut state, &previous, &all_selections); + } + let columnar_selection_ids = state + .groups + .iter() + .flat_map(|group| group.stack.iter()) + .copied() + .collect::>(); + let mut columnar_rows = ColumnarSelectionRows::new(&display_map); + let (mut columnar_selections, new_selections_to_columnarize) = + all_selections + .into_iter() + .partition::, _>(|selection| columnar_selection_ids.contains(&selection.id)); + + let mut goal_columns_by_selection_id = HashMap::default(); + let mut projected_selections = Vec::new(); + let mut projection_counts = vec![None; new_selections_to_columnarize.len()]; + if skip_soft_wrap { + let selections_by_id = columnar_selections + .iter() + .map(|selection| (selection.id, selection)) + .collect::>(); + let mut sources = Vec::new(); + for group in &mut state.groups { + let Some(oldest_selection) = + group.stack.first().and_then(|id| selections_by_id.get(id)) + else { + continue; + }; + if group.goal_source.as_ref().is_some_and(|source| { + !buffer.can_resolve(&source.start) || !buffer.can_resolve(&source.end) + }) { + group.goal_source = None; + } + let source = group + .goal_source + .get_or_insert_with(|| goal_source_for_selection(oldest_selection)); + if let Some(last_id) = group.stack.last() { + sources.push((*last_id, source.to_point(buffer))); + } + } + sources.extend( + new_selections_to_columnarize + .iter() + .map(|selection| (selection.id, selection.range())), + ); + let ranges = sources + .iter() + .map(|(_, range)| range.clone()) + .collect::>(); + goal_columns_by_selection_id.extend( + sources + .iter() + .map(|(id, _)| *id) + .zip(columnar_rows.columns_for_ranges(&ranges)), + ); + let tabs = display_map.tab_snapshot(); + let mut queries = Vec::new(); + for (selection, projection_count) in new_selections_to_columnarize + .iter() + .zip(&mut projection_counts) + { + let start = tabs.point_to_tab_point(selection.start, Bias::Left); + let end = tabs.point_to_tab_point(selection.end, Bias::Left); + if start.row() != end.row() + || display_map + .is_block_line(selection.start.to_display_point(&display_map).row()) + { + let columns = &goal_columns_by_selection_id[&selection.id]; + let query_start = queries.len(); + let mut row = start.row(); + loop { + let last_row = + if let Some(hidden) = display_map.fully_replaced_tab_rows(row) { + *hidden.end() + } else { + queries.push((row, columns.clone(), selection.reversed)); + row + }; + if last_row >= end.row() { + break; + } + row = last_row + 1; + } + *projection_count = Some(queries.len() - query_start); + } + } + projected_selections = self + .selections + .build_columnar_selections_from_tab_expanded_columns(&mut columnar_rows, &queries); + } + let mut projected_selections = projected_selections.into_iter(); + for (selection, projection_count) in new_selections_to_columnarize + .into_iter() + .zip(projection_counts) + { + if skip_soft_wrap { + let goal_source = Some(goal_source_for_selection(&selection)); + let mut stack = Vec::new(); + if let Some(count) = projection_count { + for selection in projected_selections.by_ref().take(count).flatten() { + stack.push(selection.id); + columnar_selections.push(selection); + } + } else { + stack.push(selection.id); + columnar_selections.push(selection); + } + if !stack.is_empty() { + if above { + stack.reverse(); + } + if let Some(columns) = goal_columns_by_selection_id.remove(&selection.id) + && let Some(last_id) = stack.last() + { + goal_columns_by_selection_id.insert(*last_id, columns); + } + state.groups.push(AddSelectionsGroup { + above, + stack, + goal_source, + }); + } else { + columnar_selections.push(selection); + } + continue; + } + let range = selection.display_range(&display_map).sorted(); + let start_x = display_map.x_for_display_point(range.start, &text_layout_details); + let end_x = display_map.x_for_display_point(range.end, &text_layout_details); + let positions = start_x.min(end_x)..start_x.max(end_x); + let mut stack = Vec::new(); + if range.start.row() == range.end.row() && !display_map.is_block_line(range.start.row()) + { + stack.push(selection.id); + columnar_selections.push(Selection { + goal: SelectionGoal::HorizontalRange { + start: positions.start.into(), + end: positions.end.into(), + }, + ..selection + }); + } else { + for row in range.start.row().0..=range.end.row().0 { + if let Some(selection) = self.selections.build_columnar_selection( + &display_map, + DisplayRow(row), + &positions, + selection.reversed, + &text_layout_details, + ) { + stack.push(selection.id); + columnar_selections.push(selection); + } + } + } + if !stack.is_empty() { + if above { + stack.reverse(); + } + state.groups.push(AddSelectionsGroup { + above, + stack, + goal_source: None, + }); + } else { + columnar_selections.push(selection); + } + } + + let mut final_selections = Vec::new(); + let end_row = if above { + DisplayRow(0) + } else { + display_map.max_point().row() + }; + + if skip_soft_wrap { + for selection in &mut columnar_selections { + selection.goal = SelectionGoal::None; + } + } + + let mut last_added_item_per_group = HashMap::default(); + for group in state.groups.iter_mut() { + if let Some(last_id) = group.stack.last() { + last_added_item_per_group.insert(*last_id, group); + } + } + + let mut next_logical_selections = HashMap::default(); + if skip_soft_wrap { + let queries = columnar_selections + .iter() + .filter_map(|selection| { + let group = last_added_item_per_group.get_mut(&selection.id)?; + if group.stack.len() == 1 { + group.above = above; + } + (group.above == above).then(|| { + ( + *selection, + goal_columns_by_selection_id[&selection.id].clone(), + ) + }) + }) + .collect::>(); + next_logical_selections.extend(queries.iter().map(|(selection, _)| selection.id).zip( + self.selections.find_next_columnar_selections_by_buffer_row( + &display_map, + &mut columnar_rows, + &queries, + above, + ), + )); + } + + for selection in columnar_selections { + if let Some(group) = last_added_item_per_group.get_mut(&selection.id) { + if skip_soft_wrap && group.stack.len() == 1 { + group.above = above; + } + if above == group.above { + let maybe_new_selection = if skip_soft_wrap { + next_logical_selections.remove(&selection.id).flatten() + } else { + let range = selection.display_range(&display_map).sorted(); + let row = if above { + range.start.row() + } else { + range.end.row() + }; + let positions = + if let SelectionGoal::HorizontalRange { start, end } = selection.goal { + Pixels::from(start)..Pixels::from(end) + } else { + let start_x = display_map + .x_for_display_point(range.start, &text_layout_details); + let end_x = display_map + .x_for_display_point(range.end, &text_layout_details); + start_x.min(end_x)..start_x.max(end_x) + }; + self.selections.find_next_columnar_selection_by_display_row( + &display_map, + row, + end_row, + above, + &positions, + selection.reversed, + &text_layout_details, + ) + }; + + if let Some(new_selection) = maybe_new_selection { + group.stack.push(new_selection.id); + if above { + final_selections.push(new_selection); + final_selections.push(selection); + } else { + final_selections.push(selection); + final_selections.push(new_selection); + } + } else { + final_selections.push(selection); + } + } else { + group.stack.pop(); + } + } else { + final_selections.push(selection); + } + } + + let unmerged_selections = final_selections.clone(); + self.change_selections_with_history( + SelectionEffects::default(), + history_entry, + window, + cx, + |selections| selections.select(final_selections), + ); + + let current = self.selections.all_unexpanded::(&display_map); + Self::reconcile_add_selection_groups(&mut state, &unmerged_selections, ¤t); + + if !state.groups.is_empty() { + self.add_selections_state = Some(state); + } + } + + fn select_match_ranges( + &mut self, + range: Range, + reversed: bool, + replace_newest: bool, + auto_scroll: Option, + window: &mut Window, + cx: &mut Context, + ) { + self.unfold_ranges( + std::slice::from_ref(&range), + false, + auto_scroll.is_some(), + cx, + ); + let effects = if let Some(scroll) = auto_scroll { + SelectionEffects::scroll(scroll) + } else { + SelectionEffects::no_scroll() + }; + self.change_selections(effects, window, cx, |s| { + if replace_newest { + s.delete(s.newest_anchor().id); + } + if reversed { + s.insert_range(range.end..range.start); + } else { + s.insert_range(range); + } + }); + } + + fn select_next_match_internal( + &mut self, + display_map: &DisplaySnapshot, + replace_newest: bool, + autoscroll: Option, + window: &mut Window, + cx: &mut Context, + ) -> Result<()> { + let buffer = display_map.buffer_snapshot(); + let mut selections = self.selections.all::(&display_map); + if let Some(mut select_next_state) = self.select_next_state.take() { + let query = &select_next_state.query; + if !select_next_state.done { + let first_selection = selections + .iter() + .min_by_key(|selection| selection.id) + .context("missing selection for select next action")?; + let last_selection = selections + .iter() + .max_by_key(|selection| selection.id) + .context("missing selection for select next action")?; + let mut next_selected_range = None; + + let bytes_after_last_selection = + buffer.bytes_in_range(last_selection.end..buffer.len()); + let bytes_before_first_selection = + buffer.bytes_in_range(MultiBufferOffset(0)..first_selection.start); + let query_matches = query + .stream_find_iter(bytes_after_last_selection) + .map(|result| (last_selection.end, result)) + .chain( + query + .stream_find_iter(bytes_before_first_selection) + .map(|result| (MultiBufferOffset(0), result)), + ); + + for (start_offset, query_match) in query_matches { + let query_match = query_match.context("query match for select next action")?; + let offset_range = + start_offset + query_match.start()..start_offset + query_match.end(); + + if !select_next_state.wordwise + || (!buffer.is_inside_word(offset_range.start, None) + && !buffer.is_inside_word(offset_range.end, None)) + { + let idx = selections + .partition_point(|selection| selection.end <= offset_range.start); + let overlaps = selections + .get(idx) + .map_or(false, |selection| selection.start < offset_range.end); + + if !overlaps { + next_selected_range = Some(offset_range); + break; + } + } + } + + if let Some(next_selected_range) = next_selected_range { + self.select_match_ranges( + next_selected_range, + last_selection.reversed, + replace_newest, + autoscroll, + window, + cx, + ); + } else { + select_next_state.done = true; + } + } + + self.select_next_state = Some(select_next_state); + } else { + let mut only_carets = true; + let mut same_text_selected = true; + let mut selected_text = None; + + let mut selections_iter = selections.iter().peekable(); + while let Some(selection) = selections_iter.next() { + if selection.start != selection.end { + only_carets = false; + } + + if same_text_selected { + if selected_text.is_none() { + selected_text = + Some(buffer.text_for_range(selection.range()).collect::()); + } + + if let Some(next_selection) = selections_iter.peek() { + if next_selection.len() == selection.len() { + let next_selected_text = buffer + .text_for_range(next_selection.range()) + .collect::(); + if Some(next_selected_text) != selected_text { + same_text_selected = false; + selected_text = None; + } + } else { + same_text_selected = false; + selected_text = None; + } + } + } + } + + if only_carets { + for selection in &mut selections { + let (word_range, _) = buffer.surrounding_word(selection.start, None); + selection.start = word_range.start; + selection.end = word_range.end; + selection.goal = SelectionGoal::None; + selection.reversed = false; + self.select_match_ranges( + selection.start..selection.end, + selection.reversed, + replace_newest, + autoscroll, + window, + cx, + ); + } + + if selections.len() == 1 { + let selection = selections + .last() + .expect("ensured that there's only one selection"); + let query = buffer + .text_for_range(selection.start..selection.end) + .collect::(); + let is_empty = query.is_empty(); + let select_state = + self.build_select_state(query, SelectNextOrigin::Caret, is_empty, cx)?; + self.select_next_state = Some(select_state); + } else { + self.select_next_state = None; + } + } else if let Some(selected_text) = selected_text { + self.select_next_state = Some(self.build_select_state( + selected_text, + SelectNextOrigin::Selection, + false, + cx, + )?); + self.select_next_match_internal( + display_map, + replace_newest, + autoscroll, + window, + cx, + )?; + } + } + Ok(()) + } + + fn build_query(patterns: I, case_sensitive: bool) -> Result + where + I: IntoIterator, + P: AsRef<[u8]>, + { + let mut builder = AhoCorasickBuilder::new(); + builder.ascii_case_insensitive(!case_sensitive); + builder.build(patterns) + } + + fn build_select_state( + &self, + query: String, + origin: SelectNextOrigin, + done: bool, + cx: &App, + ) -> Result { + let search_options = self.select_next_options.unwrap_or_else(|| { + let search_settings = EditorSettings::get_global(cx).search; + SelectSearchOptions { + case_sensitive: search_settings.case_sensitive, + whole_word: search_settings.whole_word, + } + }); + Ok(SelectNextState { + query: Self::build_query(&[query], search_options.case_sensitive)?, + wordwise: matches!(origin, SelectNextOrigin::Caret) || search_options.whole_word, + done, + }) + } + + fn find_syntax_node_boundary( + &self, + selection_pos: MultiBufferOffset, + move_to_end: bool, + display_map: &DisplaySnapshot, + buffer: &MultiBufferSnapshot, + ) -> MultiBufferOffset { + let old_range = selection_pos..selection_pos; + let mut new_pos = selection_pos; + let mut search_range = old_range; + while let Some((node, range)) = buffer.syntax_ancestor(search_range.clone()) { + search_range = range.clone(); + if !node.is_named() + || display_map.intersects_fold(range.start) + || display_map.intersects_fold(range.end) + // If cursor is already at the end of the syntax node, continue searching + || (move_to_end && range.end == selection_pos) + // If cursor is already at the start of the syntax node, continue searching + || (!move_to_end && range.start == selection_pos) + { + continue; + } + + // If we found a string_content node, find the largest parent that is still string_content + // Enables us to skip to the end of strings without taking multiple steps inside the string + let (_, final_range) = if node.kind() == "string_content" { + let mut current_node = node; + let mut current_range = range; + while let Some((parent, parent_range)) = + buffer.syntax_ancestor(current_range.clone()) + { + if parent.kind() == "string_content" { + current_node = parent; + current_range = parent_range; + } else { + break; + } + } + + (current_node, current_range) + } else { + (node, range) + }; + + new_pos = if move_to_end { + final_range.end + } else { + final_range.start + }; + + break; + } + + new_pos + } + + fn move_cursors_to_syntax_nodes( + &mut self, + window: &mut Window, + cx: &mut Context, + move_to_end: bool, + ) { + let old_selections: Box<[_]> = self + .selections + .all::(&self.display_snapshot(cx)) + .into(); + if old_selections.is_empty() { + return; + } + + let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); + let buffer = self.buffer.read(cx).snapshot(cx); + + let new_selections = old_selections + .iter() + .map(|selection| { + if !selection.is_empty() { + return *selection; + } + + let selection_pos = selection.head(); + let new_pos = self.find_syntax_node_boundary( + selection_pos, + move_to_end, + &display_map, + &buffer, + ); + + Selection { + id: selection.id, + start: new_pos, + end: new_pos, + goal: SelectionGoal::None, + reversed: false, + } + }) + .collect::>(); + + self.change_selections(Default::default(), window, cx, |s| { + s.select(new_selections); + }); + self.request_autoscroll(Autoscroll::newest(), cx); + } + + fn select_to_syntax_nodes( + &mut self, + window: &mut Window, + cx: &mut Context, + move_to_end: bool, + ) { + let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); + let buffer = self.buffer.read(cx).snapshot(cx); + let old_selections = self.selections.all::(&display_map); + + let new_selections = old_selections + .iter() + .map(|selection| { + let new_pos = self.find_syntax_node_boundary( + selection.head(), + move_to_end, + &display_map, + &buffer, + ); + + let mut new_selection = *selection; + new_selection.set_head(new_pos, SelectionGoal::None); + new_selection + }) + .collect::>(); + + self.change_selections(Default::default(), window, cx, |s| { + s.select(new_selections); + }); + } +}