vim: { } to navigate by paragraph (#2668)

As part of this I added `assert_shared_state()` to the
NeovimBackedTestContext so that it is more like a drop-in replacement
for the VimTestContext.

The remaining part of zed-industries/community#682 is adding bracket
matching to plain text. It looks like the current logic requires there
to be a tree sitter language for the language in order to support
bracket matching. I didn't fix this in this PR because I was unsure
whether to try and work around that, or to try and add a plain text tree
sitter language.

Release Notes:

- vim: support `{` and `}` for paragraph motion
([#470](https://github.com/zed-industries/community/issues/470)).
- vim: fix `%` at the end of the line
([#682](https://github.com/zed-industries/community/issues/682)).
This commit is contained in:
Conrad Irwin 2023-07-07 14:59:06 -06:00 committed by GitHub
commit 460bf93866
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 377 additions and 60 deletions

View file

@ -31,6 +31,8 @@ pub enum Motion {
CurrentLine,
StartOfLine,
EndOfLine,
StartOfParagraph,
EndOfParagraph,
StartOfDocument,
EndOfDocument,
Matching,
@ -72,6 +74,8 @@ actions!(
StartOfLine,
EndOfLine,
CurrentLine,
StartOfParagraph,
EndOfParagraph,
StartOfDocument,
EndOfDocument,
Matching,
@ -92,6 +96,12 @@ pub fn init(cx: &mut AppContext) {
cx.add_action(|_: &mut Workspace, _: &StartOfLine, cx: _| motion(Motion::StartOfLine, cx));
cx.add_action(|_: &mut Workspace, _: &EndOfLine, cx: _| motion(Motion::EndOfLine, cx));
cx.add_action(|_: &mut Workspace, _: &CurrentLine, cx: _| motion(Motion::CurrentLine, cx));
cx.add_action(|_: &mut Workspace, _: &StartOfParagraph, cx: _| {
motion(Motion::StartOfParagraph, cx)
});
cx.add_action(|_: &mut Workspace, _: &EndOfParagraph, cx: _| {
motion(Motion::EndOfParagraph, cx)
});
cx.add_action(|_: &mut Workspace, _: &StartOfDocument, cx: _| {
motion(Motion::StartOfDocument, cx)
});
@ -142,7 +152,8 @@ impl Motion {
pub fn linewise(&self) -> bool {
use Motion::*;
match self {
Down | Up | StartOfDocument | EndOfDocument | CurrentLine | NextLineStart => true,
Down | Up | StartOfDocument | EndOfDocument | CurrentLine | NextLineStart
| StartOfParagraph | EndOfParagraph => true,
EndOfLine
| NextWordEnd { .. }
| Matching
@ -172,6 +183,8 @@ impl Motion {
| Backspace
| Right
| StartOfLine
| StartOfParagraph
| EndOfParagraph
| NextWordStart { .. }
| PreviousWordStart { .. }
| FirstNonWhitespace
@ -197,6 +210,8 @@ impl Motion {
| Backspace
| Right
| StartOfLine
| StartOfParagraph
| EndOfParagraph
| NextWordStart { .. }
| PreviousWordStart { .. }
| FirstNonWhitespace
@ -235,6 +250,14 @@ impl Motion {
FirstNonWhitespace => (first_non_whitespace(map, point), SelectionGoal::None),
StartOfLine => (start_of_line(map, point), SelectionGoal::None),
EndOfLine => (end_of_line(map, point), SelectionGoal::None),
StartOfParagraph => (
movement::start_of_paragraph(map, point, times),
SelectionGoal::None,
),
EndOfParagraph => (
map.clip_at_line_end(movement::end_of_paragraph(map, point, times)),
SelectionGoal::None,
),
CurrentLine => (end_of_line(map, point), SelectionGoal::None),
StartOfDocument => (start_of_document(map, point, times), SelectionGoal::None),
EndOfDocument => (
@ -502,10 +525,13 @@ fn matching(map: &DisplaySnapshot, display_point: DisplayPoint) -> DisplayPoint
if line_end == point {
line_end = map.max_point().to_point(map);
}
line_end.column = line_end.column.saturating_sub(1);
let line_range = map.prev_line_boundary(point).0..line_end;
let ranges = map.buffer_snapshot.bracket_ranges(line_range.clone());
let visible_line_range =
line_range.start..Point::new(line_range.end.row, line_range.end.column.saturating_sub(1));
let ranges = map
.buffer_snapshot
.bracket_ranges(visible_line_range.clone());
if let Some(ranges) = ranges {
let line_range = line_range.start.to_offset(&map.buffer_snapshot)
..line_range.end.to_offset(&map.buffer_snapshot);
@ -590,3 +616,131 @@ fn next_line_start(map: &DisplaySnapshot, point: DisplayPoint, times: usize) ->
let new_row = (point.row() + times as u32).min(map.max_buffer_row());
map.clip_point(DisplayPoint::new(new_row, 0), Bias::Left)
}
#[cfg(test)]
mod test {
use crate::test::NeovimBackedTestContext;
use indoc::indoc;
#[gpui::test]
async fn test_start_end_of_paragraph(cx: &mut gpui::TestAppContext) {
let mut cx = NeovimBackedTestContext::new(cx).await;
let initial_state = indoc! {r"ˇabc
def
paragraph
the second
third and
final"};
// goes down once
cx.set_shared_state(initial_state).await;
cx.simulate_shared_keystrokes(["}"]).await;
cx.assert_shared_state(indoc! {r"abc
def
ˇ
paragraph
the second
third and
final"})
.await;
// goes up once
cx.simulate_shared_keystrokes(["{"]).await;
cx.assert_shared_state(initial_state).await;
// goes down twice
cx.simulate_shared_keystrokes(["2", "}"]).await;
cx.assert_shared_state(indoc! {r"abc
def
paragraph
the second
ˇ
third and
final"})
.await;
// goes down over multiple blanks
cx.simulate_shared_keystrokes(["}"]).await;
cx.assert_shared_state(indoc! {r"abc
def
paragraph
the second
third and
finaˇl"})
.await;
// goes up twice
cx.simulate_shared_keystrokes(["2", "{"]).await;
cx.assert_shared_state(indoc! {r"abc
def
ˇ
paragraph
the second
third and
final"})
.await
}
#[gpui::test]
async fn test_matching(cx: &mut gpui::TestAppContext) {
let mut cx = NeovimBackedTestContext::new(cx).await;
cx.set_shared_state(indoc! {r"func ˇ(a string) {
do(something(with<Types>.and_arrays[0, 2]))
}"})
.await;
cx.simulate_shared_keystrokes(["%"]).await;
cx.assert_shared_state(indoc! {r"func (a stringˇ) {
do(something(with<Types>.and_arrays[0, 2]))
}"})
.await;
// test it works on the last character of the line
cx.set_shared_state(indoc! {r"func (a string) ˇ{
do(something(with<Types>.and_arrays[0, 2]))
}"})
.await;
cx.simulate_shared_keystrokes(["%"]).await;
cx.assert_shared_state(indoc! {r"func (a string) {
do(something(with<Types>.and_arrays[0, 2]))
ˇ}"})
.await;
// test it works on immediate nesting
cx.set_shared_state("ˇ{()}").await;
cx.simulate_shared_keystrokes(["%"]).await;
cx.assert_shared_state("{()ˇ}").await;
cx.simulate_shared_keystrokes(["%"]).await;
cx.assert_shared_state("ˇ{()}").await;
// test it works on immediate nesting inside braces
cx.set_shared_state("{\n ˇ{()}\n}").await;
cx.simulate_shared_keystrokes(["%"]).await;
cx.assert_shared_state("{\n {()ˇ}\n}").await;
// test it jumps to the next paren on a line
cx.set_shared_state("func ˇboop() {\n}").await;
cx.simulate_shared_keystrokes(["%"]).await;
cx.assert_shared_state("func boop(ˇ) {\n}").await;
}
}

View file

@ -1,29 +1,51 @@
use editor::scroll::autoscroll::Autoscroll;
use gpui::ViewContext;
use language::Point;
use language::{Bias, Point};
use workspace::Workspace;
use crate::{motion::Motion, normal::ChangeCase, Vim};
use crate::{normal::ChangeCase, state::Mode, Vim};
pub fn change_case(_: &mut Workspace, _: &ChangeCase, cx: &mut ViewContext<Workspace>) {
Vim::update(cx, |vim, cx| {
let count = vim.pop_number_operator(cx);
let count = vim.pop_number_operator(cx).unwrap_or(1) as u32;
vim.update_active_editor(cx, |editor, cx| {
editor.set_clip_at_line_ends(false, cx);
editor.transact(cx, |editor, cx| {
editor.change_selections(None, cx, |s| {
s.move_with(|map, selection| {
if selection.start == selection.end {
Motion::Right.expand_selection(map, selection, count, true);
let mut ranges = Vec::new();
let mut cursor_positions = Vec::new();
let snapshot = editor.buffer().read(cx).snapshot(cx);
for selection in editor.selections.all::<Point>(cx) {
match vim.state.mode {
Mode::Visual { line: true } => {
let start = Point::new(selection.start.row, 0);
let end =
Point::new(selection.end.row, snapshot.line_len(selection.end.row));
ranges.push(start..end);
cursor_positions.push(start..start);
}
Mode::Visual { line: false } => {
ranges.push(selection.start..selection.end);
cursor_positions.push(selection.start..selection.start);
}
Mode::Insert | Mode::Normal => {
let start = selection.start;
let mut end = start;
for _ in 0..count {
end = snapshot.clip_point(end + Point::new(0, 1), Bias::Right);
}
})
});
let selections = editor.selections.all::<Point>(cx);
for selection in selections.into_iter().rev() {
ranges.push(start..end);
if end.column == snapshot.line_len(end.row) {
end = snapshot.clip_point(end - Point::new(0, 1), Bias::Left);
}
cursor_positions.push(end..end)
}
}
}
editor.transact(cx, |editor, cx| {
for range in ranges.into_iter().rev() {
let snapshot = editor.buffer().read(cx).snapshot(cx);
editor.buffer().update(cx, |buffer, cx| {
let range = selection.start..selection.end;
let text = snapshot
.text_for_range(selection.start..selection.end)
.text_for_range(range.start..range.end)
.flat_map(|s| s.chars())
.flat_map(|c| {
if c.is_lowercase() {
@ -37,28 +59,46 @@ pub fn change_case(_: &mut Workspace, _: &ChangeCase, cx: &mut ViewContext<Works
buffer.edit([(range, text)], None, cx)
})
}
editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
s.select_ranges(cursor_positions)
})
});
editor.set_clip_at_line_ends(true, cx);
});
vim.switch_mode(Mode::Normal, true, cx)
})
}
#[cfg(test)]
mod test {
use crate::{state::Mode, test::VimTestContext};
use indoc::indoc;
use crate::{state::Mode, test::NeovimBackedTestContext};
#[gpui::test]
async fn test_change_case(cx: &mut gpui::TestAppContext) {
let mut cx = VimTestContext::new(cx, true).await;
cx.set_state(indoc! {"ˇabC\n"}, Mode::Normal);
cx.simulate_keystrokes(["~"]);
cx.assert_editor_state("AˇbC\n");
cx.simulate_keystrokes(["2", "~"]);
cx.assert_editor_state("ABcˇ\n");
let mut cx = NeovimBackedTestContext::new(cx).await;
cx.set_shared_state("ˇabC\n").await;
cx.simulate_shared_keystrokes(["~"]).await;
cx.assert_shared_state("AˇbC\n").await;
cx.simulate_shared_keystrokes(["2", "~"]).await;
cx.assert_shared_state("ABˇc\n").await;
cx.set_state(indoc! {"a😀C«dÉ1*fˇ»\n"}, Mode::Normal);
cx.simulate_keystrokes(["~"]);
cx.assert_editor_state("a😀CDé1*Fˇ\n");
// works in visual mode
cx.set_shared_state("a😀C«dÉ1*fˇ»\n").await;
cx.simulate_shared_keystrokes(["~"]).await;
cx.assert_shared_state("a😀CˇDé1*F\n").await;
// works with multibyte characters
cx.simulate_shared_keystrokes(["~"]).await;
cx.set_shared_state("aˇC😀é1*F\n").await;
cx.simulate_shared_keystrokes(["4", "~"]).await;
cx.assert_shared_state("ac😀É1ˇ*F\n").await;
// works with line selections
cx.set_shared_state("abˇC\n").await;
cx.simulate_shared_keystrokes(["shift-v", "~"]).await;
cx.assert_shared_state("ˇABc\n").await;
// works with multiple cursors (zed only)
cx.set_state("aˇßcdˇe\n", Mode::Normal);
cx.simulate_keystroke("~");
cx.assert_state("aSSˇcdˇE\n", Mode::Normal);
}
}

View file

@ -1,9 +1,10 @@
use std::ops::{Deref, DerefMut};
use indoc::indoc;
use std::ops::{Deref, DerefMut, Range};
use collections::{HashMap, HashSet};
use gpui::ContextHandle;
use language::OffsetRangeExt;
use util::test::marked_text_offsets;
use util::test::{generate_marked_text, marked_text_offsets};
use super::{neovim_connection::NeovimConnection, NeovimBackedBindingTestContext, VimTestContext};
use crate::state::Mode;
@ -112,6 +113,43 @@ impl<'a> NeovimBackedTestContext<'a> {
context_handle
}
pub async fn assert_shared_state(&mut self, marked_text: &str) {
let neovim = self.neovim_state().await;
if neovim != marked_text {
panic!(
indoc! {"Test is incorrect (currently expected != neovim state)
# currently expected:
{}
# neovim state:
{}
# zed state:
{}"},
marked_text,
neovim,
self.editor_state(),
)
}
self.assert_editor_state(marked_text)
}
pub async fn neovim_state(&mut self) -> String {
generate_marked_text(
self.neovim.text().await.as_str(),
&vec![self.neovim_selection().await],
true,
)
}
async fn neovim_selection(&mut self) -> Range<usize> {
let mut neovim_selection = self.neovim.selection().await;
// Zed selections adjust themselves to make the end point visually make sense
if neovim_selection.start > neovim_selection.end {
neovim_selection.start.column += 1;
}
neovim_selection.to_offset(&self.buffer_snapshot())
}
pub async fn assert_state_matches(&mut self) {
assert_eq!(
self.neovim.text().await,
@ -120,13 +158,8 @@ impl<'a> NeovimBackedTestContext<'a> {
self.assertion_context()
);
let mut neovim_selection = self.neovim.selection().await;
// Zed selections adjust themselves to make the end point visually make sense
if neovim_selection.start > neovim_selection.end {
neovim_selection.start.column += 1;
}
let neovim_selection = neovim_selection.to_offset(&self.buffer_snapshot());
self.assert_editor_selections(vec![neovim_selection]);
let selections = vec![self.neovim_selection().await];
self.assert_editor_selections(selections);
if let Some(neovim_mode) = self.neovim.mode().await {
assert_eq!(neovim_mode, self.mode(), "{}", self.assertion_context(),);

View file

@ -167,15 +167,25 @@ impl NeovimConnection {
.await
.expect("Could not get neovim window");
if !selection.is_empty() {
panic!("Setting neovim state with non empty selection not yet supported");
}
let cursor = selection.start;
nvim_window
.set_cursor((cursor.row as i64 + 1, cursor.column as i64))
.await
.expect("Could not set nvim cursor position");
if !selection.is_empty() {
self.nvim
.input("v")
.await
.expect("could not enter visual mode");
let cursor = selection.end;
nvim_window
.set_cursor((cursor.row as i64 + 1, cursor.column as i64))
.await
.expect("Could not set nvim cursor position");
}
if let Some(NeovimData::Get { mode, state }) = self.data.back() {
if *mode == Some(Mode::Normal) && *state == marked_text {
return;