Files
zed/crates/vim/src/normal/substitute.rs
T
Conrad Irwin fc269dfaf9 vim: Handle exclusive-linewise edgecase correctly (#27786)
Before this change we didn't explicitly handle vim's exclusive-linewise
edgecase
(https://neovim.io/doc/user/motion.html#exclusive).

Instead we had hard-coded workarounds in a few places to make our tests
pass.
The most pernicious of these workarounds was that we represented a
visual line
selection as including the trailing newline (or leading newline for
files that
end with no newline), which other code had to undo to get back to what
the user
indended.

Closes #21440
Updates #6900

Release Notes:

- vim: Fixed `d]}` to not delete the closing brace
- vim: Fixed `d}` from the start of the line to not delete the paragraph
separator
- vim: Fixed `d}` from the middle of the line to not delete the final
newline
2025-03-31 10:36:20 -06:00

298 lines
9.5 KiB
Rust

use editor::{movement, Editor};
use gpui::{actions, Context, Window};
use language::Point;
use crate::{
motion::{Motion, MotionKind},
Mode, Vim,
};
actions!(vim, [Substitute, SubstituteLine]);
pub(crate) fn register(editor: &mut Editor, cx: &mut Context<Vim>) {
Vim::action(editor, cx, |vim, _: &Substitute, window, cx| {
vim.start_recording(cx);
let count = Vim::take_count(cx);
vim.substitute(count, vim.mode == Mode::VisualLine, window, cx);
});
Vim::action(editor, cx, |vim, _: &SubstituteLine, window, cx| {
vim.start_recording(cx);
if matches!(vim.mode, Mode::VisualBlock | Mode::Visual) {
vim.switch_mode(Mode::VisualLine, false, window, cx)
}
let count = Vim::take_count(cx);
vim.substitute(count, true, window, cx)
});
}
impl Vim {
pub fn substitute(
&mut self,
count: Option<usize>,
line_mode: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.store_visual_marks(window, cx);
self.update_editor(window, cx, |vim, editor, window, cx| {
editor.set_clip_at_line_ends(false, cx);
editor.transact(window, cx, |editor, window, cx| {
let text_layout_details = editor.text_layout_details(window);
editor.change_selections(None, window, cx, |s| {
s.move_with(|map, selection| {
if selection.start == selection.end {
Motion::Right.expand_selection(
map,
selection,
count,
&text_layout_details,
);
}
if line_mode {
// in Visual mode when the selection contains the newline at the end
// of the line, we should exclude it.
if !selection.is_empty() && selection.end.column() == 0 {
selection.end = movement::left(map, selection.end);
}
Motion::CurrentLine.expand_selection(
map,
selection,
None,
&text_layout_details,
);
if let Some((point, _)) = (Motion::FirstNonWhitespace {
display_lines: false,
})
.move_point(
map,
selection.start,
selection.goal,
None,
&text_layout_details,
) {
selection.start = point;
}
}
})
});
let kind = if line_mode {
MotionKind::Linewise
} else {
MotionKind::Exclusive
};
vim.copy_selections_content(editor, kind, window, cx);
let selections = editor.selections.all::<Point>(cx).into_iter();
let edits = selections.map(|selection| (selection.start..selection.end, ""));
editor.edit(edits, cx);
});
});
self.switch_mode(Mode::Insert, true, window, cx);
}
}
#[cfg(test)]
mod test {
use crate::{
state::Mode,
test::{NeovimBackedTestContext, VimTestContext},
};
use indoc::indoc;
#[gpui::test]
async fn test_substitute(cx: &mut gpui::TestAppContext) {
let mut cx = VimTestContext::new(cx, true).await;
// supports a single cursor
cx.set_state(indoc! {"ˇabc\n"}, Mode::Normal);
cx.simulate_keystrokes("s x");
cx.assert_editor_state("xˇbc\n");
// supports a selection
cx.set_state(indoc! {"a«bcˇ»\n"}, Mode::Visual);
cx.assert_editor_state("a«bcˇ»\n");
cx.simulate_keystrokes("s x");
cx.assert_editor_state("axˇ\n");
// supports counts
cx.set_state(indoc! {"ˇabc\n"}, Mode::Normal);
cx.simulate_keystrokes("2 s x");
cx.assert_editor_state("xˇc\n");
// supports multiple cursors
cx.set_state(indoc! {"a«bcˇ»deˇffg\n"}, Mode::Normal);
cx.simulate_keystrokes("2 s x");
cx.assert_editor_state("axˇdexˇg\n");
// does not read beyond end of line
cx.set_state(indoc! {"ˇabc\n"}, Mode::Normal);
cx.simulate_keystrokes("5 s x");
cx.assert_editor_state("xˇ\n");
// it handles multibyte characters
cx.set_state(indoc! {"ˇcàfé\n"}, Mode::Normal);
cx.simulate_keystrokes("4 s");
cx.assert_editor_state("ˇ\n");
// should transactionally undo selection changes
cx.simulate_keystrokes("escape u");
cx.assert_editor_state("ˇcàfé\n");
// it handles visual line mode
cx.set_state(
indoc! {"
alpha
beˇta
gamma"},
Mode::Normal,
);
cx.simulate_keystrokes("shift-v s");
cx.assert_editor_state(indoc! {"
alpha
ˇ
gamma"});
}
#[gpui::test]
async fn test_visual_change(cx: &mut gpui::TestAppContext) {
let mut cx = NeovimBackedTestContext::new(cx).await;
cx.set_shared_state("The quick ˇbrown").await;
cx.simulate_shared_keystrokes("v w c").await;
cx.shared_state().await.assert_eq("The quick ˇ");
cx.set_shared_state(indoc! {"
The ˇquick brown
fox jumps over
the lazy dog"})
.await;
cx.simulate_shared_keystrokes("v w j c").await;
cx.shared_state().await.assert_eq(indoc! {"
The ˇver
the lazy dog"});
cx.simulate_at_each_offset(
"v w j c",
indoc! {"
The ˇquick brown
fox jumps ˇover
the ˇlazy dog"},
)
.await
.assert_matches();
cx.simulate_at_each_offset(
"v w k c",
indoc! {"
The ˇquick brown
fox jumps ˇover
the ˇlazy dog"},
)
.await
.assert_matches();
}
#[gpui::test]
async fn test_visual_line_change(cx: &mut gpui::TestAppContext) {
let mut cx = NeovimBackedTestContext::new(cx).await;
cx.simulate(
"shift-v c",
indoc! {"
The quˇick brown
fox jumps over
the lazy dog"},
)
.await
.assert_matches();
// Test pasting code copied on change
cx.simulate_shared_keystrokes("escape j p").await;
cx.shared_state().await.assert_matches();
cx.simulate_at_each_offset(
"shift-v c",
indoc! {"
The quick brown
fox juˇmps over
the laˇzy dog"},
)
.await
.assert_matches();
cx.simulate(
"shift-v j c",
indoc! {"
The quˇick brown
fox jumps over
the lazy dog"},
)
.await
.assert_matches();
// Test pasting code copied on delete
cx.simulate_shared_keystrokes("escape j p").await;
cx.shared_state().await.assert_matches();
cx.simulate_at_each_offset(
"shift-v j c",
indoc! {"
The quick brown
fox juˇmps over
the laˇzy dog"},
)
.await
.assert_matches();
}
#[gpui::test]
async fn test_substitute_line(cx: &mut gpui::TestAppContext) {
let mut cx = NeovimBackedTestContext::new(cx).await;
let initial_state = indoc! {"
The quick brown
fox juˇmps over
the lazy dog
"};
// normal mode
cx.set_shared_state(initial_state).await;
cx.simulate_shared_keystrokes("shift-s o").await;
cx.shared_state().await.assert_eq(indoc! {"
The quick brown
oˇ
the lazy dog
"});
// visual mode
cx.set_shared_state(initial_state).await;
cx.simulate_shared_keystrokes("v k shift-s o").await;
cx.shared_state().await.assert_eq(indoc! {"
oˇ
the lazy dog
"});
// visual block mode
cx.set_shared_state(initial_state).await;
cx.simulate_shared_keystrokes("ctrl-v j shift-s o").await;
cx.shared_state().await.assert_eq(indoc! {"
The quick brown
oˇ
"});
// visual mode including newline
cx.set_shared_state(initial_state).await;
cx.simulate_shared_keystrokes("v $ shift-s o").await;
cx.shared_state().await.assert_eq(indoc! {"
The quick brown
oˇ
the lazy dog
"});
// indentation
cx.set_neovim_option("shiftwidth=4").await;
cx.set_shared_state(initial_state).await;
cx.simulate_shared_keystrokes("> > shift-s o").await;
cx.shared_state().await.assert_eq(indoc! {"
The quick brown
oˇ
the lazy dog
"});
}
}