Adds word and sentence text objects along with a new vim testing system which uses cached neovim data to verify our test accuracy

This commit is contained in:
K Simmons 2022-10-05 20:19:30 -07:00
parent e96abf1429
commit b82db3a254
945 changed files with 5678 additions and 655 deletions

1197
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -38,7 +38,23 @@
}
],
"%": "vim::Matching",
"escape": "editor::Cancel"
"escape": "editor::Cancel",
"i": [
"vim::PushOperator",
{
"Object": {
"around": false
}
}
],
"a": [
"vim::PushOperator",
{
"Object": {
"around": true
}
}
]
}
},
{
@ -134,6 +150,20 @@
"y": "vim::CurrentLine"
}
},
{
"context": "Editor && VimObject",
"bindings": {
"w": "vim::Word",
"shift-w": [
"vim::Word",
{
"ignorePunctuation": true
}
],
"s": "vim::Sentence",
"p": "vim::Paragraph"
}
},
{
"context": "Editor && vim_mode == visual",
"bindings": {

View file

@ -330,34 +330,91 @@ impl DisplaySnapshot {
DisplayPoint(self.blocks_snapshot.max_point())
}
/// Returns text chunks starting at the given display row until the end of the file
pub fn text_chunks(&self, display_row: u32) -> impl Iterator<Item = &str> {
self.blocks_snapshot
.chunks(display_row..self.max_point().row() + 1, false, None)
.map(|h| h.text)
}
// Returns text chunks starting at the end of the given display row in reverse until the start of the file
pub fn reverse_text_chunks(&self, display_row: u32) -> impl Iterator<Item = &str> {
(0..=display_row).into_iter().rev().flat_map(|row| {
self.blocks_snapshot
.chunks(row..row + 1, false, None)
.map(|h| h.text)
.collect::<Vec<_>>()
.into_iter()
.rev()
})
}
pub fn chunks(&self, display_rows: Range<u32>, language_aware: bool) -> DisplayChunks<'_> {
self.blocks_snapshot
.chunks(display_rows, language_aware, Some(&self.text_highlights))
}
pub fn chars_at(&self, point: DisplayPoint) -> impl Iterator<Item = char> + '_ {
let mut column = 0;
let mut chars = self.text_chunks(point.row()).flat_map(str::chars);
while column < point.column() {
if let Some(c) = chars.next() {
column += c.len_utf8() as u32;
} else {
break;
}
}
chars
pub fn chars_at(
&self,
mut point: DisplayPoint,
) -> impl Iterator<Item = (char, DisplayPoint)> + '_ {
point = DisplayPoint(self.blocks_snapshot.clip_point(point.0, Bias::Left));
self.text_chunks(point.row())
.flat_map(str::chars)
.skip_while({
let mut column = 0;
move |char| {
let at_point = column >= point.column();
column += char.len_utf8() as u32;
!at_point
}
})
.map(move |ch| {
let result = (ch, point);
if ch == '\n' {
*point.row_mut() += 1;
*point.column_mut() = 0;
} else {
*point.column_mut() += ch.len_utf8() as u32;
}
result
})
}
pub fn reverse_chars_at(
&self,
mut point: DisplayPoint,
) -> impl Iterator<Item = (char, DisplayPoint)> + '_ {
point = DisplayPoint(self.blocks_snapshot.clip_point(point.0, Bias::Left));
self.reverse_text_chunks(point.row())
.flat_map(|chunk| chunk.chars().rev())
.skip_while({
let mut column = self.line_len(point.row());
if self.max_point().row() > point.row() {
column += 1;
}
move |char| {
let at_point = column <= point.column();
column = column.saturating_sub(char.len_utf8() as u32);
!at_point
}
})
.map(move |ch| {
if ch == '\n' {
*point.row_mut() -= 1;
*point.column_mut() = self.line_len(point.row());
} else {
*point.column_mut() = point.column().saturating_sub(ch.len_utf8() as u32);
}
(ch, point)
})
}
pub fn column_to_chars(&self, display_row: u32, target: u32) -> u32 {
let mut count = 0;
let mut column = 0;
for c in self.chars_at(DisplayPoint::new(display_row, 0)) {
for (c, _) in self.chars_at(DisplayPoint::new(display_row, 0)) {
if column >= target {
break;
}
@ -370,7 +427,7 @@ impl DisplaySnapshot {
pub fn column_from_chars(&self, display_row: u32, char_count: u32) -> u32 {
let mut column = 0;
for (count, c) in self.chars_at(DisplayPoint::new(display_row, 0)).enumerate() {
for (count, (c, _)) in self.chars_at(DisplayPoint::new(display_row, 0)).enumerate() {
if c == '\n' || count >= char_count as usize {
break;
}
@ -454,7 +511,7 @@ impl DisplaySnapshot {
pub fn line_indent(&self, display_row: u32) -> (u32, bool) {
let mut indent = 0;
let mut is_blank = true;
for c in self.chars_at(DisplayPoint::new(display_row, 0)) {
for (c, _) in self.chars_at(DisplayPoint::new(display_row, 0)) {
if c == ' ' {
indent += 1;
} else {

View file

@ -4074,7 +4074,7 @@ impl Editor {
self.change_selections(Some(Autoscroll::Fit), cx, |s| {
s.move_cursors_with(|map, head, _| {
(
movement::line_beginning(map, head, true),
movement::indented_line_beginning(map, head, true),
SelectionGoal::None,
)
});
@ -4089,7 +4089,7 @@ impl Editor {
self.change_selections(Some(Autoscroll::Fit), cx, |s| {
s.move_heads_with(|map, head, _| {
(
movement::line_beginning(map, head, action.stop_at_soft_wraps),
movement::indented_line_beginning(map, head, action.stop_at_soft_wraps),
SelectionGoal::None,
)
});

View file

@ -752,7 +752,7 @@ impl EditorElement {
.snapshot
.chars_at(cursor_position)
.next()
.and_then(|character| {
.and_then(|(character, _)| {
let font_id =
cursor_row_layout.font_for_index(cursor_column)?;
let text = character.to_string();

View file

@ -101,6 +101,22 @@ pub fn line_beginning(
map: &DisplaySnapshot,
display_point: DisplayPoint,
stop_at_soft_boundaries: bool,
) -> DisplayPoint {
let point = display_point.to_point(map);
let soft_line_start = map.clip_point(DisplayPoint::new(display_point.row(), 0), Bias::Right);
let line_start = map.prev_line_boundary(point).1;
if stop_at_soft_boundaries && display_point != soft_line_start {
soft_line_start
} else {
line_start
}
}
pub fn indented_line_beginning(
map: &DisplaySnapshot,
display_point: DisplayPoint,
stop_at_soft_boundaries: bool,
) -> DisplayPoint {
let point = display_point.to_point(map);
let soft_line_start = map.clip_point(DisplayPoint::new(display_point.row(), 0), Bias::Right);
@ -167,54 +183,79 @@ pub fn next_subword_end(map: &DisplaySnapshot, point: DisplayPoint) -> DisplayPo
})
}
/// Scans for a boundary from the start of each line preceding the given end point until a boundary
/// is found, indicated by the given predicate returning true. The predicate is called with the
/// character to the left and right of the candidate boundary location, and will be called with `\n`
/// characters indicating the start or end of a line. If the predicate returns true multiple times
/// on a line, the *rightmost* boundary is returned.
/// Scans for a boundary preceding the given start point `from` until a boundary is found, indicated by the
/// given predicate returning true. The predicate is called with the character to the left and right
/// of the candidate boundary location, and will be called with `\n` characters indicating the start
/// or end of a line.
pub fn find_preceding_boundary(
map: &DisplaySnapshot,
end: DisplayPoint,
from: DisplayPoint,
mut is_boundary: impl FnMut(char, char) -> bool,
) -> DisplayPoint {
let mut point = end;
loop {
*point.column_mut() = 0;
if point.row() > 0 {
if let Some(indent) = map.soft_wrap_indent(point.row() - 1) {
*point.column_mut() = indent;
let mut start_column = 0;
let mut soft_wrap_row = from.row() + 1;
let mut prev = None;
for (ch, point) in map.reverse_chars_at(from) {
// Recompute soft_wrap_indent if the row has changed
if point.row() != soft_wrap_row {
soft_wrap_row = point.row();
if point.row() == 0 {
start_column = 0;
} else if let Some(indent) = map.soft_wrap_indent(point.row() - 1) {
start_column = indent;
}
}
let mut boundary = None;
let mut prev_ch = if point.is_zero() { None } else { Some('\n') };
for ch in map.chars_at(point) {
if point >= end {
break;
}
if let Some(prev_ch) = prev_ch {
if is_boundary(prev_ch, ch) {
boundary = Some(point);
}
}
if ch == '\n' {
break;
}
prev_ch = Some(ch);
*point.column_mut() += ch.len_utf8() as u32;
// If the current point is in the soft_wrap, skip comparing it
if point.column() < start_column {
continue;
}
if let Some(boundary) = boundary {
return boundary;
} else if point.row() == 0 {
return DisplayPoint::zero();
} else {
*point.row_mut() -= 1;
if let Some((prev_ch, prev_point)) = prev {
if is_boundary(ch, prev_ch) {
return prev_point;
}
}
prev = Some((ch, point));
}
DisplayPoint::zero()
}
/// Scans for a boundary preceding the given start point `from` until a boundary is found, indicated by the
/// given predicate returning true. The predicate is called with the character to the left and right
/// of the candidate boundary location, and will be called with `\n` characters indicating the start
/// or end of a line. If no boundary is found, the start of the line is returned.
pub fn find_preceding_boundary_in_line(
map: &DisplaySnapshot,
from: DisplayPoint,
mut is_boundary: impl FnMut(char, char) -> bool,
) -> DisplayPoint {
let mut start_column = 0;
if from.row() > 0 {
if let Some(indent) = map.soft_wrap_indent(from.row() - 1) {
start_column = indent;
}
}
let mut prev = None;
for (ch, point) in map.reverse_chars_at(from) {
if let Some((prev_ch, prev_point)) = prev {
if is_boundary(ch, prev_ch) {
return prev_point;
}
}
if ch == '\n' || point.column() < start_column {
break;
}
prev = Some((ch, point));
}
prev.map(|(_, point)| point).unwrap_or(from)
}
/// Scans for a boundary following the given start point until a boundary is found, indicated by the
@ -223,26 +264,48 @@ pub fn find_preceding_boundary(
/// or end of a line.
pub fn find_boundary(
map: &DisplaySnapshot,
mut point: DisplayPoint,
from: DisplayPoint,
mut is_boundary: impl FnMut(char, char) -> bool,
) -> DisplayPoint {
let mut prev_ch = None;
for ch in map.chars_at(point) {
for (ch, point) in map.chars_at(from) {
if let Some(prev_ch) = prev_ch {
if is_boundary(prev_ch, ch) {
break;
return map.clip_point(point, Bias::Right);
}
}
if ch == '\n' {
*point.row_mut() += 1;
*point.column_mut() = 0;
} else {
*point.column_mut() += ch.len_utf8() as u32;
}
prev_ch = Some(ch);
}
map.clip_point(point, Bias::Right)
map.clip_point(map.max_point(), Bias::Right)
}
/// Scans for a boundary following the given start point until a boundary is found, indicated by the
/// given predicate returning true. The predicate is called with the character to the left and right
/// of the candidate boundary location, and will be called with `\n` characters indicating the start
/// or end of a line. If no boundary is found, the end of the line is returned
pub fn find_boundary_in_line(
map: &DisplaySnapshot,
from: DisplayPoint,
mut is_boundary: impl FnMut(char, char) -> bool,
) -> DisplayPoint {
let mut prev = None;
for (ch, point) in map.chars_at(from) {
if let Some((prev_ch, _)) = prev {
if is_boundary(prev_ch, ch) {
return map.clip_point(point, Bias::Right);
}
}
prev = Some((ch, point));
if ch == '\n' {
break;
}
}
// Return the last position checked so that we give a point right before the newline or eof.
map.clip_point(prev.map(|(_, point)| point).unwrap_or(from), Bias::Right)
}
pub fn is_inside_word(map: &DisplaySnapshot, point: DisplayPoint) -> bool {

View file

@ -7,7 +7,20 @@ edition = "2021"
path = "src/vim.rs"
doctest = false
[features]
neovim = ["nvim-rs", "async-compat", "async-trait", "tokio"]
[dependencies]
serde = { version = "1.0", features = ["derive", "rc"] }
itertools = "0.10"
log = { version = "0.4.16", features = ["kv_unstable_serde"] }
async-compat = { version = "0.2.1", "optional" = true }
async-trait = { version = "0.1", "optional" = true }
nvim-rs = { git = "https://github.com/KillTheMule/nvim-rs", branch = "master", features = ["use_tokio"], optional = true }
tokio = { version = "1.15", "optional" = true }
serde_json = { version = "1.0", features = ["preserve_order"] }
assets = { path = "../assets" }
collections = { path = "../collections" }
command_palette = { path = "../command_palette" }
@ -15,14 +28,12 @@ editor = { path = "../editor" }
gpui = { path = "../gpui" }
language = { path = "../language" }
search = { path = "../search" }
serde = { version = "1.0", features = ["derive", "rc"] }
settings = { path = "../settings" }
workspace = { path = "../workspace" }
itertools = "0.10"
log = { version = "0.4.16", features = ["kv_unstable_serde"] }
[dev-dependencies]
indoc = "1.0.4"
editor = { path = "../editor", features = ["test-support"] }
gpui = { path = "../gpui", features = ["test-support"] }
language = { path = "../language", features = ["test-support"] }

View file

@ -26,7 +26,7 @@ fn normal_before(_: &mut Workspace, _: &NormalBefore, cx: &mut ViewContext<Works
#[cfg(test)]
mod test {
use crate::{state::Mode, vim_test_context::VimTestContext};
use crate::{state::Mode, test_contexts::VimTestContext};
#[gpui::test]
async fn test_enter_and_exit_insert_mode(cx: &mut gpui::TestAppContext) {

View file

@ -206,7 +206,7 @@ impl Motion {
}
}
selection.end = map.next_line_boundary(selection.end.to_point(map)).1;
(_, selection.end) = map.next_line_boundary(selection.end.to_point(map));
} else {
// If the motion is exclusive and the end of the motion is in column 1, the
// end of the motion is moved to the end of the previous line and the motion
@ -239,12 +239,12 @@ fn left(map: &DisplaySnapshot, mut point: DisplayPoint) -> DisplayPoint {
map.clip_point(point, Bias::Left)
}
fn right(map: &DisplaySnapshot, mut point: DisplayPoint) -> DisplayPoint {
pub(crate) fn right(map: &DisplaySnapshot, mut point: DisplayPoint) -> DisplayPoint {
*point.column_mut() += 1;
map.clip_point(point, Bias::Right)
}
fn next_word_start(
pub(crate) fn next_word_start(
map: &DisplaySnapshot,
point: DisplayPoint,
ignore_punctuation: bool,
@ -255,7 +255,7 @@ fn next_word_start(
let right_kind = char_kind(right).coerce_punctuation(ignore_punctuation);
let at_newline = right == '\n';
let found = (left_kind != right_kind && !right.is_whitespace())
let found = (left_kind != right_kind && right_kind != CharKind::Whitespace)
|| at_newline && crossed_newline
|| at_newline && left == '\n'; // Prevents skipping repeated empty lines
@ -272,23 +272,28 @@ fn next_word_end(
ignore_punctuation: bool,
) -> DisplayPoint {
*point.column_mut() += 1;
dbg!(point);
point = movement::find_boundary(map, point, |left, right| {
dbg!(left);
let left_kind = char_kind(left).coerce_punctuation(ignore_punctuation);
let right_kind = char_kind(right).coerce_punctuation(ignore_punctuation);
left_kind != right_kind && !left.is_whitespace()
left_kind != right_kind && left_kind != CharKind::Whitespace
});
dbg!(point);
// find_boundary clips, so if the character after the next character is a newline or at the end of the document, we know
// we have backtraced already
if !map
.chars_at(point)
.nth(1)
.map(|c| c == '\n')
.map(|(c, _)| c == '\n')
.unwrap_or(true)
{
*point.column_mut() = point.column().saturating_sub(1);
}
map.clip_point(point, Bias::Left)
dbg!(map.clip_point(point, Bias::Left))
}
fn previous_word_start(
@ -307,22 +312,21 @@ fn previous_word_start(
point
}
fn first_non_whitespace(map: &DisplaySnapshot, mut point: DisplayPoint) -> DisplayPoint {
let mut column = 0;
for ch in map.chars_at(DisplayPoint::new(point.row(), 0)) {
fn first_non_whitespace(map: &DisplaySnapshot, from: DisplayPoint) -> DisplayPoint {
let mut last_point = DisplayPoint::new(from.row(), 0);
for (ch, point) in map.chars_at(last_point) {
if ch == '\n' {
return point;
return from;
}
last_point = point;
if char_kind(ch) != CharKind::Whitespace {
break;
}
column += ch.len_utf8() as u32;
}
*point.column_mut() = column;
map.clip_point(point, Bias::Left)
map.clip_point(last_point, Bias::Left)
}
fn start_of_line(map: &DisplaySnapshot, point: DisplayPoint) -> DisplayPoint {

View file

@ -6,6 +6,7 @@ use std::borrow::Cow;
use crate::{
motion::Motion,
object::Object,
state::{Mode, Operator},
Vim,
};
@ -16,7 +17,11 @@ use gpui::{actions, MutableAppContext, ViewContext};
use language::{AutoindentMode, Point, SelectionGoal};
use workspace::Workspace;
use self::{change::change_over, delete::delete_over, yank::yank_over};
use self::{
change::{change_motion, change_object},
delete::{delete_motion, delete_object},
yank::{yank_motion, yank_object},
};
actions!(
vim,
@ -43,22 +48,22 @@ pub fn init(cx: &mut MutableAppContext) {
cx.add_action(insert_line_below);
cx.add_action(|_: &mut Workspace, _: &DeleteLeft, cx| {
Vim::update(cx, |vim, cx| {
delete_over(vim, Motion::Left, cx);
delete_motion(vim, Motion::Left, cx);
})
});
cx.add_action(|_: &mut Workspace, _: &DeleteRight, cx| {
Vim::update(cx, |vim, cx| {
delete_over(vim, Motion::Right, cx);
delete_motion(vim, Motion::Right, cx);
})
});
cx.add_action(|_: &mut Workspace, _: &ChangeToEndOfLine, cx| {
Vim::update(cx, |vim, cx| {
change_over(vim, Motion::EndOfLine, cx);
change_motion(vim, Motion::EndOfLine, cx);
})
});
cx.add_action(|_: &mut Workspace, _: &DeleteToEndOfLine, cx| {
Vim::update(cx, |vim, cx| {
delete_over(vim, Motion::EndOfLine, cx);
delete_motion(vim, Motion::EndOfLine, cx);
})
});
cx.add_action(paste);
@ -70,17 +75,36 @@ pub fn normal_motion(motion: Motion, cx: &mut MutableAppContext) {
Vim::update(cx, |vim, cx| {
match vim.state.operator_stack.pop() {
None => move_cursor(vim, motion, cx),
Some(Operator::Namespace(_)) => {
// Can't do anything for a namespace operator. Ignoring
Some(Operator::Change) => change_motion(vim, motion, cx),
Some(Operator::Delete) => delete_motion(vim, motion, cx),
Some(Operator::Yank) => yank_motion(vim, motion, cx),
_ => {
// Can't do anything for text objects or namespace operators. Ignoring
}
Some(Operator::Change) => change_over(vim, motion, cx),
Some(Operator::Delete) => delete_over(vim, motion, cx),
Some(Operator::Yank) => yank_over(vim, motion, cx),
}
vim.clear_operator(cx);
});
}
pub fn normal_object(object: Object, cx: &mut MutableAppContext) {
Vim::update(cx, |vim, cx| {
match vim.state.operator_stack.pop() {
Some(Operator::Object { around }) => match vim.state.operator_stack.pop() {
Some(Operator::Change) => change_object(vim, object, around, cx),
Some(Operator::Delete) => delete_object(vim, object, around, cx),
Some(Operator::Yank) => yank_object(vim, object, around, cx),
_ => {
// Can't do anything for namespace operators. Ignoring
}
},
_ => {
// Can't do anything with change/delete/yank and text objects. Ignoring
}
}
vim.clear_operator(cx);
})
}
fn move_cursor(vim: &mut Vim, motion: Motion, cx: &mut MutableAppContext) {
vim.update_active_editor(cx, |editor, cx| {
editor.change_selections(Some(Autoscroll::Fit), cx, |s| {
@ -304,7 +328,7 @@ mod test {
Mode::{self, *},
Namespace, Operator,
},
vim_test_context::VimTestContext,
test_contexts::VimTestContext,
};
#[gpui::test]

View file

@ -1,4 +1,4 @@
use crate::{motion::Motion, state::Mode, utils::copy_selections_content, Vim};
use crate::{motion::Motion, object::Object, state::Mode, utils::copy_selections_content, Vim};
use editor::{char_kind, movement, Autoscroll};
use gpui::{impl_actions, MutableAppContext, ViewContext};
use serde::Deserialize;
@ -17,7 +17,7 @@ pub fn init(cx: &mut MutableAppContext) {
cx.add_action(change_word);
}
pub fn change_over(vim: &mut Vim, motion: Motion, cx: &mut MutableAppContext) {
pub fn change_motion(vim: &mut Vim, motion: Motion, cx: &mut MutableAppContext) {
vim.update_active_editor(cx, |editor, cx| {
editor.transact(cx, |editor, cx| {
// We are swapping to insert mode anyway. Just set the line end clipping behavior now
@ -34,6 +34,23 @@ pub fn change_over(vim: &mut Vim, motion: Motion, cx: &mut MutableAppContext) {
vim.switch_mode(Mode::Insert, false, cx)
}
pub fn change_object(vim: &mut Vim, object: Object, around: bool, cx: &mut MutableAppContext) {
vim.update_active_editor(cx, |editor, cx| {
editor.transact(cx, |editor, cx| {
// We are swapping to insert mode anyway. Just set the line end clipping behavior now
editor.set_clip_at_line_ends(false, cx);
editor.change_selections(Some(Autoscroll::Fit), cx, |s| {
s.move_with(|map, selection| {
object.expand_selection(map, selection, around);
});
});
copy_selections_content(editor, false, cx);
editor.insert("", cx);
});
});
vim.switch_mode(Mode::Insert, false, cx);
}
// From the docs https://vimhelp.org/change.txt.html#cw
// Special case: When the cursor is in a word, "cw" and "cW" do not include the
// white space after a word, they only change up to the end of the word. This is
@ -78,7 +95,7 @@ fn change_word(
mod test {
use indoc::indoc;
use crate::{state::Mode, vim_test_context::VimTestContext};
use crate::{state::Mode, test_contexts::VimTestContext};
#[gpui::test]
async fn test_change_h(cx: &mut gpui::TestAppContext) {
@ -170,8 +187,7 @@ mod test {
test"},
indoc! {"
Test test
ˇ
test"},
ˇ"},
);
let mut cx = cx.binding(["c", "shift-e"]);
@ -193,6 +209,7 @@ mod test {
Test ˇ
test"},
);
println!("Marker");
cx.assert(
indoc! {"
Test test

View file

@ -1,9 +1,9 @@
use crate::{motion::Motion, utils::copy_selections_content, Vim};
use collections::HashMap;
use editor::{Autoscroll, Bias};
use crate::{motion::Motion, object::Object, utils::copy_selections_content, Vim};
use collections::{HashMap, HashSet};
use editor::{display_map::ToDisplayPoint, Autoscroll, Bias};
use gpui::MutableAppContext;
pub fn delete_over(vim: &mut Vim, motion: Motion, cx: &mut MutableAppContext) {
pub fn delete_motion(vim: &mut Vim, motion: Motion, cx: &mut MutableAppContext) {
vim.update_active_editor(cx, |editor, cx| {
editor.transact(cx, |editor, cx| {
editor.set_clip_at_line_ends(false, cx);
@ -36,11 +36,67 @@ pub fn delete_over(vim: &mut Vim, motion: Motion, cx: &mut MutableAppContext) {
});
}
pub fn delete_object(vim: &mut Vim, object: Object, around: bool, cx: &mut MutableAppContext) {
vim.update_active_editor(cx, |editor, cx| {
editor.transact(cx, |editor, cx| {
editor.set_clip_at_line_ends(false, cx);
// Emulates behavior in vim where if we expanded backwards to include a newline
// the cursor gets set back to the start of the line
let mut should_move_to_start: HashSet<_> = Default::default();
editor.change_selections(Some(Autoscroll::Fit), cx, |s| {
s.move_with(|map, selection| {
object.expand_selection(map, selection, around);
let offset_range = selection.map(|p| p.to_offset(map, Bias::Left)).range();
let contains_only_newlines = map
.chars_at(selection.start)
.take_while(|(_, p)| p < &selection.end)
.all(|(char, _)| char == '\n')
|| offset_range.is_empty();
let end_at_newline = map
.chars_at(selection.end)
.next()
.map(|(c, _)| c == '\n')
.unwrap_or(false);
// If expanded range contains only newlines and
// the object is around or sentence, expand to include a newline
// at the end or start
if (around || object == Object::Sentence) && contains_only_newlines {
if end_at_newline {
selection.end =
(offset_range.end + '\n'.len_utf8()).to_display_point(map);
} else if selection.start.row() > 0 {
should_move_to_start.insert(selection.id);
selection.start =
(offset_range.start - '\n'.len_utf8()).to_display_point(map);
}
}
});
});
copy_selections_content(editor, false, cx);
editor.insert("", cx);
// Fixup cursor position after the deletion
editor.set_clip_at_line_ends(true, cx);
editor.change_selections(Some(Autoscroll::Fit), cx, |s| {
s.move_with(|map, selection| {
let mut cursor = selection.head();
if should_move_to_start.contains(&selection.id) {
*cursor.column_mut() = 0;
}
cursor = map.clip_point(cursor, Bias::Left);
selection.collapse_to(cursor, selection.goal)
});
});
});
});
}
#[cfg(test)]
mod test {
use indoc::indoc;
use crate::{state::Mode, vim_test_context::VimTestContext};
use crate::{state::Mode, test_contexts::VimTestContext};
#[gpui::test]
async fn test_delete_h(cx: &mut gpui::TestAppContext) {
@ -140,8 +196,7 @@ mod test {
test"},
indoc! {"
Test test
ˇ
test"},
ˇ"},
);
let mut cx = cx.binding(["d", "shift-e"]);

View file

@ -1,8 +1,8 @@
use crate::{motion::Motion, utils::copy_selections_content, Vim};
use crate::{motion::Motion, object::Object, utils::copy_selections_content, Vim};
use collections::HashMap;
use gpui::MutableAppContext;
pub fn yank_over(vim: &mut Vim, motion: Motion, cx: &mut MutableAppContext) {
pub fn yank_motion(vim: &mut Vim, motion: Motion, cx: &mut MutableAppContext) {
vim.update_active_editor(cx, |editor, cx| {
editor.transact(cx, |editor, cx| {
editor.set_clip_at_line_ends(false, cx);
@ -24,3 +24,26 @@ pub fn yank_over(vim: &mut Vim, motion: Motion, cx: &mut MutableAppContext) {
});
});
}
pub fn yank_object(vim: &mut Vim, object: Object, around: bool, cx: &mut MutableAppContext) {
vim.update_active_editor(cx, |editor, cx| {
editor.transact(cx, |editor, cx| {
editor.set_clip_at_line_ends(false, cx);
let mut original_positions: HashMap<_, _> = Default::default();
editor.change_selections(None, cx, |s| {
s.move_with(|map, selection| {
let original_position = (selection.head(), selection.goal);
object.expand_selection(map, selection, around);
original_positions.insert(selection.id, original_position);
});
});
copy_selections_content(editor, false, cx);
editor.change_selections(None, cx, |s| {
s.move_with(|_, selection| {
let (head, goal) = original_positions.remove(&selection.id).unwrap();
selection.collapse_to(head, goal);
});
});
});
});
}

488
crates/vim/src/object.rs Normal file
View file

@ -0,0 +1,488 @@
use std::ops::Range;
use editor::{char_kind, display_map::DisplaySnapshot, movement, Bias, CharKind, DisplayPoint};
use gpui::{actions, impl_actions, MutableAppContext};
use language::Selection;
use serde::Deserialize;
use workspace::Workspace;
use crate::{motion, normal::normal_object, state::Mode, visual::visual_object, Vim};
#[derive(Copy, Clone, Debug, PartialEq)]
pub enum Object {
Word { ignore_punctuation: bool },
Sentence,
Paragraph,
}
#[derive(Clone, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
struct Word {
#[serde(default)]
ignore_punctuation: bool,
}
actions!(vim, [Sentence, Paragraph]);
impl_actions!(vim, [Word]);
pub fn init(cx: &mut MutableAppContext) {
cx.add_action(
|_: &mut Workspace, &Word { ignore_punctuation }: &Word, cx: _| {
object(Object::Word { ignore_punctuation }, cx)
},
);
cx.add_action(|_: &mut Workspace, _: &Sentence, cx: _| object(Object::Sentence, cx));
cx.add_action(|_: &mut Workspace, _: &Paragraph, cx: _| object(Object::Paragraph, cx));
}
fn object(object: Object, cx: &mut MutableAppContext) {
match Vim::read(cx).state.mode {
Mode::Normal => normal_object(object, cx),
Mode::Visual { .. } => visual_object(object, cx),
Mode::Insert => {
// Shouldn't execute a text object in insert mode. Ignoring
}
}
}
impl Object {
pub fn object_range(
self,
map: &DisplaySnapshot,
relative_to: DisplayPoint,
around: bool,
) -> Range<DisplayPoint> {
match self {
Object::Word { ignore_punctuation } => {
if around {
around_word(map, relative_to, ignore_punctuation)
} else {
in_word(map, relative_to, ignore_punctuation)
}
}
Object::Sentence => sentence(map, relative_to, around),
_ => relative_to..relative_to,
}
}
pub fn expand_selection(
self,
map: &DisplaySnapshot,
selection: &mut Selection<DisplayPoint>,
around: bool,
) {
let range = self.object_range(map, selection.head(), around);
selection.start = range.start;
selection.end = range.end;
}
}
/// Return a range that surrounds the word relative_to is in
/// If relative_to is at the start of a word, return the word.
/// If relative_to is between words, return the space between
fn in_word(
map: &DisplaySnapshot,
relative_to: DisplayPoint,
ignore_punctuation: bool,
) -> Range<DisplayPoint> {
// Use motion::right so that we consider the character under the cursor when looking for the start
let start = movement::find_preceding_boundary_in_line(
map,
motion::right(map, relative_to),
|left, right| {
char_kind(left).coerce_punctuation(ignore_punctuation)
!= char_kind(right).coerce_punctuation(ignore_punctuation)
},
);
let end = movement::find_boundary_in_line(map, relative_to, |left, right| {
char_kind(left).coerce_punctuation(ignore_punctuation)
!= char_kind(right).coerce_punctuation(ignore_punctuation)
});
start..end
}
/// Return a range that surrounds the word and following whitespace
/// relative_to is in.
/// If relative_to is at the start of a word, return the word and following whitespace.
/// If relative_to is between words, return the whitespace back and the following word
/// if in word
/// delete that word
/// if there is whitespace following the word, delete that as well
/// otherwise, delete any preceding whitespace
/// otherwise
/// delete whitespace around cursor
/// delete word following the cursor
fn around_word(
map: &DisplaySnapshot,
relative_to: DisplayPoint,
ignore_punctuation: bool,
) -> Range<DisplayPoint> {
let in_word = map
.chars_at(relative_to)
.next()
.map(|(c, _)| char_kind(c) != CharKind::Whitespace)
.unwrap_or(false);
if in_word {
around_containing_word(map, relative_to, ignore_punctuation)
} else {
around_next_word(map, relative_to, ignore_punctuation)
}
}
fn around_containing_word(
map: &DisplaySnapshot,
relative_to: DisplayPoint,
ignore_punctuation: bool,
) -> Range<DisplayPoint> {
expand_to_include_whitespace(map, in_word(map, relative_to, ignore_punctuation), true)
}
fn around_next_word(
map: &DisplaySnapshot,
relative_to: DisplayPoint,
ignore_punctuation: bool,
) -> Range<DisplayPoint> {
// Get the start of the word
let start = movement::find_preceding_boundary_in_line(
map,
motion::right(map, relative_to),
|left, right| {
char_kind(left).coerce_punctuation(ignore_punctuation)
!= char_kind(right).coerce_punctuation(ignore_punctuation)
},
);
let mut word_found = false;
let end = movement::find_boundary(map, relative_to, |left, right| {
let left_kind = char_kind(left).coerce_punctuation(ignore_punctuation);
let right_kind = char_kind(right).coerce_punctuation(ignore_punctuation);
let found = (word_found && left_kind != right_kind) || right == '\n' && left == '\n';
if right_kind != CharKind::Whitespace {
word_found = true;
}
found
});
start..end
}
// /// Return the range containing a sentence.
// fn sentence(map: &DisplaySnapshot, relative_to: DisplayPoint, around: bool) -> Range<DisplayPoint> {
// let mut previous_end = relative_to;
// let mut start = None;
// // Seek backwards to find a period or double newline. Record the last non whitespace character as the
// // possible start of the sentence. Alternatively if two newlines are found right after each other, return that.
// let mut rev_chars = map.reverse_chars_at(relative_to).peekable();
// while let Some((char, point)) = rev_chars.next() {
// dbg!(char, point);
// if char == '.' {
// break;
// }
// if char == '\n'
// && (rev_chars.peek().map(|(c, _)| c == &'\n').unwrap_or(false) || start.is_none())
// {
// break;
// }
// if !char.is_whitespace() {
// start = Some(point);
// }
// previous_end = point;
// }
// let mut end = relative_to;
// let mut chars = map.chars_at(relative_to).peekable();
// while let Some((char, point)) = chars.next() {
// if !char.is_whitespace() {
// if start.is_none() {
// start = Some(point);
// }
// // Set the end to the point after the current non whitespace character
// end = point;
// *end.column_mut() += char.len_utf8() as u32;
// }
// if char == '.' {
// break;
// }
// if char == '\n' {
// if start.is_none() {
// if let Some((_, next_point)) = chars.peek() {
// end = *next_point;
// }
// break;
// if chars.peek().map(|(c, _)| c == &'\n').unwrap_or(false) {
// break;
// }
// }
// }
// start.unwrap_or(previous_end)..end
// }
fn sentence(map: &DisplaySnapshot, relative_to: DisplayPoint, around: bool) -> Range<DisplayPoint> {
let mut start = None;
let mut previous_end = relative_to;
for (char, point) in map.reverse_chars_at(relative_to) {
if is_sentence_end(map, point) {
break;
}
if is_possible_sentence_start(char) {
start = Some(point);
}
previous_end = point;
}
// Handle case where cursor was before the sentence start
let mut chars = map.chars_at(relative_to).peekable();
if start.is_none() {
if let Some((char, point)) = chars.peek() {
if is_possible_sentence_start(*char) {
start = Some(*point);
}
}
}
let mut end = relative_to;
for (char, point) in chars {
if start.is_some() {
if !char.is_whitespace() {
end = point;
*end.column_mut() += char.len_utf8() as u32;
end = map.clip_point(end, Bias::Left);
}
if is_sentence_end(map, point) {
break;
}
} else if is_possible_sentence_start(char) {
if around {
start = Some(point);
} else {
end = point;
break;
}
}
}
let mut range = start.unwrap_or(previous_end)..end;
if around {
range = expand_to_include_whitespace(map, range, false);
}
range
}
fn is_possible_sentence_start(character: char) -> bool {
!character.is_whitespace() && character != '.'
}
const SENTENCE_END_PUNCTUATION: &[char] = &['.', '!', '?'];
const SENTENCE_END_FILLERS: &[char] = &[')', ']', '"', '\''];
const SENTENCE_END_WHITESPACE: &[char] = &[' ', '\t', '\n'];
fn is_sentence_end(map: &DisplaySnapshot, point: DisplayPoint) -> bool {
let mut chars = map.chars_at(point).peekable();
if let Some((char, _)) = chars.next() {
if char == '\n' && chars.peek().map(|(c, _)| c == &'\n').unwrap_or(false) {
return true;
}
if !SENTENCE_END_PUNCTUATION.contains(&char) {
return false;
}
} else {
return false;
}
for (char, _) in chars {
if SENTENCE_END_WHITESPACE.contains(&char) {
return true;
}
if !SENTENCE_END_FILLERS.contains(&char) {
return false;
}
}
return true;
}
/// Expands the passed range to include whitespace on one side or the other in a line. Attempts to add the
/// whitespace to the end first and falls back to the start if there was none.
fn expand_to_include_whitespace(
map: &DisplaySnapshot,
mut range: Range<DisplayPoint>,
stop_at_newline: bool,
) -> Range<DisplayPoint> {
let mut whitespace_included = false;
for (char, point) in map.chars_at(range.end) {
range.end = point;
if char == '\n' && stop_at_newline {
break;
}
if char.is_whitespace() {
whitespace_included = true;
} else {
break;
}
}
if !whitespace_included {
for (char, point) in map.reverse_chars_at(range.start) {
if char == '\n' && stop_at_newline {
break;
}
if !char.is_whitespace() {
break;
}
range.start = point;
}
}
range
}
#[cfg(test)]
mod test {
use indoc::indoc;
use crate::test_contexts::NeovimBackedTestContext;
const WORD_LOCATIONS: &'static str = indoc! {"
The quick ˇbrowˇnˇ
fox ˇjuˇmpsˇ over
the lazy dogˇ
ˇ
ˇ
ˇ
Thˇeˇ-ˇquˇickˇ ˇbrownˇ
ˇ
ˇ
ˇ fox-jumpˇs over
the lazy dogˇ
ˇ
"};
#[gpui::test]
async fn test_change_in_word(cx: &mut gpui::TestAppContext) {
let mut cx = NeovimBackedTestContext::new("test_change_in_word", cx)
.await
.binding(["c", "i", "w"]);
cx.assert_all(WORD_LOCATIONS).await;
let mut cx = cx.consume().binding(["c", "i", "shift-w"]);
cx.assert_all(WORD_LOCATIONS).await;
}
#[gpui::test]
async fn test_delete_in_word(cx: &mut gpui::TestAppContext) {
let mut cx = NeovimBackedTestContext::new("test_delete_in_word", cx)
.await
.binding(["d", "i", "w"]);
cx.assert_all(WORD_LOCATIONS).await;
let mut cx = cx.consume().binding(["d", "i", "shift-w"]);
cx.assert_all(WORD_LOCATIONS).await;
}
#[gpui::test]
async fn test_change_around_word(cx: &mut gpui::TestAppContext) {
let mut cx = NeovimBackedTestContext::new("test_change_around_word", cx)
.await
.binding(["c", "a", "w"]);
cx.assert_all(WORD_LOCATIONS).await;
let mut cx = cx.consume().binding(["c", "a", "shift-w"]);
cx.assert_all(WORD_LOCATIONS).await;
}
#[gpui::test]
async fn test_delete_around_word(cx: &mut gpui::TestAppContext) {
let mut cx = NeovimBackedTestContext::new("test_delete_around_word", cx)
.await
.binding(["d", "a", "w"]);
cx.assert_all(WORD_LOCATIONS).await;
let mut cx = cx.consume().binding(["d", "a", "shift-w"]);
cx.assert_all(WORD_LOCATIONS).await;
}
const SENTENCE_EXAMPLES: &[&'static str] = &[
"ˇThe quick ˇbrownˇ?ˇ ˇFox Jˇumpsˇ!ˇ Ovˇer theˇ lazyˇ.",
indoc! {"
ˇThe quick ˇbrownˇ
fox jumps over
the lazy doˇgˇ.ˇ ˇThe quick ˇ
brown fox jumps over
"},
// Double newlines are broken currently
// indoc! {"
// The quick brown fox jumps.
// Over the lazy dog
// ˇ
// ˇ
// ˇ fox-jumpˇs over
// the lazy dog.ˇ
// ˇ
// "},
r#"The quick brown.)]'" Brown fox jumps."#,
];
#[gpui::test]
async fn test_change_in_sentence(cx: &mut gpui::TestAppContext) {
let mut cx = NeovimBackedTestContext::new("test_change_in_sentence", cx)
.await
.binding(["c", "i", "s"]);
for sentence_example in SENTENCE_EXAMPLES {
cx.assert_all(sentence_example).await;
}
}
#[gpui::test]
async fn test_delete_in_sentence(cx: &mut gpui::TestAppContext) {
let mut cx = NeovimBackedTestContext::new("test_delete_in_sentence", cx)
.await
.binding(["d", "i", "s"]);
for sentence_example in SENTENCE_EXAMPLES {
cx.assert_all(sentence_example).await;
}
}
#[gpui::test]
#[ignore] // End cursor position is incorrect
async fn test_change_around_sentence(cx: &mut gpui::TestAppContext) {
let mut cx = NeovimBackedTestContext::new("test_change_around_sentence", cx)
.await
.binding(["c", "a", "s"]);
for sentence_example in SENTENCE_EXAMPLES {
cx.assert_all(sentence_example).await;
}
}
#[gpui::test]
#[ignore] // End cursor position is incorrect
async fn test_delete_around_sentence(cx: &mut gpui::TestAppContext) {
let mut cx = NeovimBackedTestContext::new("test_delete_around_sentence", cx)
.await
.binding(["d", "a", "s"]);
for sentence_example in SENTENCE_EXAMPLES {
cx.assert_all(sentence_example).await;
}
}
}

View file

@ -1,8 +1,8 @@
use editor::CursorShape;
use gpui::keymap::Context;
use serde::Deserialize;
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)]
pub enum Mode {
Normal,
Insert,
@ -26,6 +26,7 @@ pub enum Operator {
Change,
Delete,
Yank,
Object { around: bool },
}
#[derive(Default)]
@ -77,7 +78,12 @@ impl VimState {
context.set.insert("VimControl".to_string());
}
Operator::set_context(self.operator_stack.last(), &mut context);
let active_operator = self.operator_stack.last();
if matches!(active_operator, Some(Operator::Object { .. })) {
context.set.insert("VimObject".to_string());
}
Operator::set_context(active_operator, &mut context);
context
}
@ -87,6 +93,8 @@ impl Operator {
pub fn set_context(operator: Option<&Operator>, context: &mut Context) {
let operator_context = match operator {
Some(Operator::Namespace(Namespace::G)) => "g",
Some(Operator::Object { around: false }) => "i",
Some(Operator::Object { around: true }) => "a",
Some(Operator::Change) => "c",
Some(Operator::Delete) => "d",
Some(Operator::Yank) => "y",

View file

@ -0,0 +1,9 @@
mod neovim_backed_binding_test_context;
mod neovim_backed_test_context;
mod vim_binding_test_context;
mod vim_test_context;
pub use neovim_backed_binding_test_context::*;
pub use neovim_backed_test_context::*;
pub use vim_binding_test_context::*;
pub use vim_test_context::*;

View file

@ -0,0 +1,56 @@
use std::ops::{Deref, DerefMut};
use util::test::marked_text_offsets;
use super::NeovimBackedTestContext;
pub struct NeovimBackedBindingTestContext<'a, const COUNT: usize> {
cx: NeovimBackedTestContext<'a>,
keystrokes_under_test: [&'static str; COUNT],
}
impl<'a, const COUNT: usize> NeovimBackedBindingTestContext<'a, COUNT> {
pub fn new(
keystrokes_under_test: [&'static str; COUNT],
cx: NeovimBackedTestContext<'a>,
) -> Self {
Self {
cx,
keystrokes_under_test,
}
}
pub fn consume(self) -> NeovimBackedTestContext<'a> {
self.cx
}
pub async fn assert(&mut self, initial_state: &str) {
self.cx
.assert_binding_matches(self.keystrokes_under_test, initial_state)
.await
}
pub async fn assert_all(&mut self, marked_positions: &str) {
let (unmarked_text, cursor_offsets) = marked_text_offsets(marked_positions);
for cursor_offset in cursor_offsets.iter() {
let mut marked_text = unmarked_text.clone();
marked_text.insert(*cursor_offset, 'ˇ');
self.assert(&marked_text).await;
}
}
}
impl<'a, const COUNT: usize> Deref for NeovimBackedBindingTestContext<'a, COUNT> {
type Target = NeovimBackedTestContext<'a>;
fn deref(&self) -> &Self::Target {
&self.cx
}
}
impl<'a, const COUNT: usize> DerefMut for NeovimBackedBindingTestContext<'a, COUNT> {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.cx
}
}

View file

@ -0,0 +1,374 @@
use std::{
ops::{Deref, DerefMut},
path::PathBuf,
};
use editor::DisplayPoint;
use gpui::keymap::Keystroke;
#[cfg(feature = "neovim")]
use async_compat::Compat;
#[cfg(feature = "neovim")]
use async_trait::async_trait;
#[cfg(feature = "neovim")]
use nvim_rs::{
create::tokio::new_child_cmd, error::LoopError, Handler, Neovim, UiAttachOptions, Value,
};
#[cfg(feature = "neovim")]
use tokio::{
process::{Child, ChildStdin, Command},
task::JoinHandle,
};
use crate::state::Mode;
use super::{NeovimBackedBindingTestContext, VimTestContext};
pub struct NeovimBackedTestContext<'a> {
cx: VimTestContext<'a>,
test_case_id: &'static str,
data_counter: usize,
#[cfg(feature = "neovim")]
nvim: Neovim<nvim_rs::compat::tokio::Compat<ChildStdin>>,
#[cfg(feature = "neovim")]
_join_handle: JoinHandle<Result<(), Box<LoopError>>>,
#[cfg(feature = "neovim")]
_child: Child,
}
impl<'a> NeovimBackedTestContext<'a> {
pub async fn new(
test_case_id: &'static str,
cx: &'a mut gpui::TestAppContext,
) -> NeovimBackedTestContext<'a> {
let cx = VimTestContext::new(cx, true).await;
#[cfg(feature = "neovim")]
let handler = NvimHandler {};
#[cfg(feature = "neovim")]
let (nvim, join_handle, child) = Compat::new(async {
let (nvim, join_handle, child) = new_child_cmd(
&mut Command::new("nvim").arg("--embed").arg("--clean"),
handler,
)
.await
.expect("Could not connect to neovim process");
nvim.ui_attach(100, 100, &UiAttachOptions::default())
.await
.expect("Could not attach to ui");
(nvim, join_handle, child)
})
.await;
let result = Self {
cx,
test_case_id,
data_counter: 0,
#[cfg(feature = "neovim")]
nvim,
#[cfg(feature = "neovim")]
_join_handle: join_handle,
#[cfg(feature = "neovim")]
_child: child,
};
#[cfg(feature = "neovim")]
{
result.clear_test_data()
}
result
}
pub async fn simulate_shared_keystroke(&mut self, keystroke_text: &str) {
let keystroke = Keystroke::parse(keystroke_text).unwrap();
#[cfg(feature = "neovim")]
{
let special = keystroke.shift
|| keystroke.ctrl
|| keystroke.alt
|| keystroke.cmd
|| keystroke.key.len() > 1;
let start = if special { "<" } else { "" };
let shift = if keystroke.shift { "S-" } else { "" };
let ctrl = if keystroke.ctrl { "C-" } else { "" };
let alt = if keystroke.alt { "M-" } else { "" };
let cmd = if keystroke.cmd { "D-" } else { "" };
let end = if special { ">" } else { "" };
let key = format!("{start}{shift}{ctrl}{alt}{cmd}{}{end}", keystroke.key);
self.nvim
.input(&key)
.await
.expect("Could not input keystroke");
}
let window_id = self.window_id;
self.cx.dispatch_keystroke(window_id, keystroke, false);
}
pub async fn simulate_shared_keystrokes<const COUNT: usize>(
&mut self,
keystroke_texts: [&str; COUNT],
) {
for keystroke_text in keystroke_texts.into_iter() {
self.simulate_shared_keystroke(keystroke_text).await;
}
}
pub async fn set_shared_state(&mut self, marked_text: &str) {
self.set_state(marked_text, Mode::Normal);
#[cfg(feature = "neovim")]
{
let cursor_point =
self.editor(|editor, cx| editor.selections.newest::<language::Point>(cx));
let nvim_buffer = self
.nvim
.get_current_buf()
.await
.expect("Could not get neovim buffer");
let mut lines = self
.buffer_text()
.lines()
.map(|line| line.to_string())
.collect::<Vec<_>>();
if lines.len() > 1 {
// Add final newline which is missing from buffer_text
lines.push("".to_string());
}
nvim_buffer
.set_lines(0, -1, false, lines)
.await
.expect("Could not set nvim buffer text");
self.nvim
.input("<escape>")
.await
.expect("Could not send escape to nvim");
self.nvim
.input("<escape>")
.await
.expect("Could not send escape to nvim");
let nvim_window = self
.nvim
.get_current_win()
.await
.expect("Could not get neovim window");
nvim_window
.set_cursor((
cursor_point.head().row as i64 + 1,
cursor_point.head().column as i64,
))
.await
.expect("Could not set nvim cursor position");
}
}
pub async fn assert_state_matches(&mut self) {
assert_eq!(self.neovim_text().await, self.buffer_text());
let zed_head = self.update_editor(|editor, cx| editor.selections.newest_display(cx).head());
assert_eq!(self.neovim_head().await, zed_head);
if let Some(neovim_mode) = self.neovim_mode().await {
assert_eq!(neovim_mode, self.mode());
}
}
#[cfg(feature = "neovim")]
pub async fn neovim_text(&mut self) -> String {
let nvim_buffer = self
.nvim
.get_current_buf()
.await
.expect("Could not get neovim buffer");
let text = nvim_buffer
.get_lines(0, -1, false)
.await
.expect("Could not get buffer text")
.join("\n");
self.write_test_data(text.clone(), "text");
text
}
#[cfg(not(feature = "neovim"))]
pub async fn neovim_text(&mut self) -> String {
self.read_test_data("text")
}
#[cfg(feature = "neovim")]
pub async fn neovim_head(&mut self) -> DisplayPoint {
let nvim_row: u32 = self
.nvim
.command_output("echo line('.')")
.await
.unwrap()
.parse::<u32>()
.unwrap()
- 1; // Neovim rows start at 1
let nvim_column: u32 = self
.nvim
.command_output("echo col('.')")
.await
.unwrap()
.parse::<u32>()
.unwrap()
- 1; // Neovim columns start at 1
let serialized = format!("{},{}", nvim_row.to_string(), nvim_column.to_string());
self.write_test_data(serialized, "head");
DisplayPoint::new(nvim_row, nvim_column)
}
#[cfg(not(feature = "neovim"))]
pub async fn neovim_head(&mut self) -> DisplayPoint {
let serialized = self.read_test_data("head");
let mut components = serialized.split(',');
let nvim_row = components.next().unwrap().parse::<u32>().unwrap();
let nvim_column = components.next().unwrap().parse::<u32>().unwrap();
DisplayPoint::new(nvim_row, nvim_column)
}
#[cfg(feature = "neovim")]
pub async fn neovim_mode(&mut self) -> Option<Mode> {
let nvim_mode_text = self
.nvim
.get_mode()
.await
.expect("Could not get mode")
.into_iter()
.find_map(|(key, value)| {
if key.as_str() == Some("mode") {
Some(value.as_str().unwrap().to_owned())
} else {
None
}
})
.expect("Could not find mode value");
let mode = match nvim_mode_text.as_ref() {
"i" => Some(Mode::Insert),
"n" => Some(Mode::Normal),
"v" => Some(Mode::Visual { line: false }),
"V" => Some(Mode::Visual { line: true }),
_ => None,
};
let serialized = serde_json::to_string(&mode).expect("Could not serialize mode");
self.write_test_data(serialized, "mode");
mode
}
#[cfg(not(feature = "neovim"))]
pub async fn neovim_mode(&mut self) -> Option<Mode> {
let serialized = self.read_test_data("mode");
serde_json::from_str(&serialized).expect("Could not deserialize test data")
}
fn test_data_directory(&self) -> PathBuf {
let mut data_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
data_path.push("test_data");
data_path.push(self.test_case_id);
data_path
}
fn next_data_path(&mut self, kind: &str) -> PathBuf {
let mut data_path = self.test_data_directory();
data_path.push(format!("{}{}.txt", self.data_counter, kind));
self.data_counter += 1;
data_path
}
#[cfg(not(feature = "neovim"))]
fn read_test_data(&mut self, kind: &str) -> String {
let path = self.next_data_path(kind);
std::fs::read_to_string(path).expect(
"Could not read test data. Is it generated? Try running test with '--features neovim'",
)
}
#[cfg(feature = "neovim")]
fn write_test_data(&mut self, data: String, kind: &str) {
let path = self.next_data_path(kind);
std::fs::create_dir_all(path.parent().unwrap())
.expect("Could not create test data directory");
std::fs::write(path, data).expect("Could not write out test data");
}
#[cfg(feature = "neovim")]
fn clear_test_data(&self) {
// If the path does not exist, no biggy, we will create it
std::fs::remove_dir_all(self.test_data_directory()).ok();
}
pub async fn assert_binding_matches<const COUNT: usize>(
&mut self,
keystrokes: [&str; COUNT],
initial_state: &str,
) {
dbg!(keystrokes, initial_state);
self.set_shared_state(initial_state).await;
self.simulate_shared_keystrokes(keystrokes).await;
self.assert_state_matches().await;
}
pub fn binding<const COUNT: usize>(
self,
keystrokes: [&'static str; COUNT],
) -> NeovimBackedBindingTestContext<'a, COUNT> {
NeovimBackedBindingTestContext::new(keystrokes, self)
}
}
#[derive(Clone)]
struct NvimHandler {}
#[cfg(feature = "neovim")]
#[async_trait]
impl Handler for NvimHandler {
type Writer = nvim_rs::compat::tokio::Compat<ChildStdin>;
async fn handle_request(
&self,
_event_name: String,
_arguments: Vec<Value>,
_neovim: Neovim<Self::Writer>,
) -> Result<Value, Value> {
unimplemented!();
}
async fn handle_notify(
&self,
_event_name: String,
_arguments: Vec<Value>,
_neovim: Neovim<Self::Writer>,
) {
}
}
impl<'a> Deref for NeovimBackedTestContext<'a> {
type Target = VimTestContext<'a>;
fn deref(&self) -> &Self::Target {
&self.cx
}
}
impl<'a> DerefMut for NeovimBackedTestContext<'a> {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.cx
}
}

View file

@ -0,0 +1,69 @@
use std::ops::{Deref, DerefMut};
use crate::*;
use super::VimTestContext;
pub struct VimBindingTestContext<'a, const COUNT: usize> {
cx: VimTestContext<'a>,
keystrokes_under_test: [&'static str; COUNT],
mode_before: Mode,
mode_after: Mode,
}
impl<'a, const COUNT: usize> VimBindingTestContext<'a, COUNT> {
pub fn new(
keystrokes_under_test: [&'static str; COUNT],
mode_before: Mode,
mode_after: Mode,
cx: VimTestContext<'a>,
) -> Self {
Self {
cx,
keystrokes_under_test,
mode_before,
mode_after,
}
}
pub fn binding<const NEW_COUNT: usize>(
self,
keystrokes_under_test: [&'static str; NEW_COUNT],
) -> VimBindingTestContext<'a, NEW_COUNT> {
VimBindingTestContext {
keystrokes_under_test,
cx: self.cx,
mode_before: self.mode_before,
mode_after: self.mode_after,
}
}
pub fn mode_after(mut self, mode_after: Mode) -> Self {
self.mode_after = mode_after;
self
}
pub fn assert(&mut self, initial_state: &str, state_after: &str) {
self.cx.assert_binding(
self.keystrokes_under_test,
initial_state,
self.mode_before,
state_after,
self.mode_after,
)
}
}
impl<'a, const COUNT: usize> Deref for VimBindingTestContext<'a, COUNT> {
type Target = VimTestContext<'a>;
fn deref(&self) -> &Self::Target {
&self.cx
}
}
impl<'a, const COUNT: usize> DerefMut for VimBindingTestContext<'a, COUNT> {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.cx
}
}

View file

@ -8,6 +8,8 @@ use workspace::{pane, AppState, WorkspaceHandle};
use crate::{state::Operator, *};
use super::VimBindingTestContext;
pub struct VimTestContext<'a> {
cx: EditorTestContext<'a>,
workspace: ViewHandle<Workspace>,
@ -168,67 +170,3 @@ impl<'a> DerefMut for VimTestContext<'a> {
&mut self.cx
}
}
pub struct VimBindingTestContext<'a, const COUNT: usize> {
cx: VimTestContext<'a>,
keystrokes_under_test: [&'static str; COUNT],
mode_before: Mode,
mode_after: Mode,
}
impl<'a, const COUNT: usize> VimBindingTestContext<'a, COUNT> {
pub fn new(
keystrokes_under_test: [&'static str; COUNT],
mode_before: Mode,
mode_after: Mode,
cx: VimTestContext<'a>,
) -> Self {
Self {
cx,
keystrokes_under_test,
mode_before,
mode_after,
}
}
pub fn binding<const NEW_COUNT: usize>(
self,
keystrokes_under_test: [&'static str; NEW_COUNT],
) -> VimBindingTestContext<'a, NEW_COUNT> {
VimBindingTestContext {
keystrokes_under_test,
cx: self.cx,
mode_before: self.mode_before,
mode_after: self.mode_after,
}
}
pub fn mode_after(mut self, mode_after: Mode) -> Self {
self.mode_after = mode_after;
self
}
pub fn assert(&mut self, initial_state: &str, state_after: &str) {
self.cx.assert_binding(
self.keystrokes_under_test,
initial_state,
self.mode_before,
state_after,
self.mode_after,
)
}
}
impl<'a, const COUNT: usize> Deref for VimBindingTestContext<'a, COUNT> {
type Target = VimTestContext<'a>;
fn deref(&self) -> &Self::Target {
&self.cx
}
}
impl<'a, const COUNT: usize> DerefMut for VimBindingTestContext<'a, COUNT> {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.cx
}
}

View file

@ -1,10 +1,11 @@
#[cfg(test)]
mod vim_test_context;
mod test_contexts;
mod editor_events;
mod insert;
mod motion;
mod normal;
mod object;
mod state;
mod utils;
mod visual;
@ -32,6 +33,7 @@ pub fn init(cx: &mut MutableAppContext) {
normal::init(cx);
visual::init(cx);
insert::init(cx);
object::init(cx);
motion::init(cx);
// Vim Actions
@ -144,7 +146,8 @@ impl Vim {
}
fn pop_operator(&mut self, cx: &mut MutableAppContext) -> Operator {
let popped_operator = self.state.operator_stack.pop().expect("Operator popped when no operator was on the stack. This likely means there is an invalid keymap config");
let popped_operator = self.state.operator_stack.pop()
.expect("Operator popped when no operator was on the stack. This likely means there is an invalid keymap config");
self.sync_vim_settings(cx);
popped_operator
}
@ -210,7 +213,10 @@ mod test {
use indoc::indoc;
use search::BufferSearchBar;
use crate::{state::Mode, vim_test_context::VimTestContext};
use crate::{
state::Mode,
test_contexts::{NeovimBackedTestContext, VimTestContext},
};
#[gpui::test]
async fn test_initially_disabled(cx: &mut gpui::TestAppContext) {
@ -219,6 +225,19 @@ mod test {
cx.assert_editor_state("hjklˇ");
}
#[gpui::test]
async fn test_neovim(cx: &mut gpui::TestAppContext) {
let mut cx = NeovimBackedTestContext::new("test_neovim", cx).await;
cx.simulate_shared_keystroke("i").await;
cx.simulate_shared_keystrokes([
"shift-T", "e", "s", "t", " ", "t", "e", "s", "t", "escape", "0", "d", "w",
])
.await;
cx.assert_state_matches().await;
cx.assert_editor_state("ˇtest");
}
#[gpui::test]
async fn test_toggle_through_settings(cx: &mut gpui::TestAppContext) {
let mut cx = VimTestContext::new(cx, true).await;

View file

@ -6,7 +6,7 @@ use gpui::{actions, MutableAppContext, ViewContext};
use language::{AutoindentMode, SelectionGoal};
use workspace::Workspace;
use crate::{motion::Motion, state::Mode, utils::copy_selections_content, Vim};
use crate::{motion::Motion, object::Object, state::Mode, utils::copy_selections_content, Vim};
actions!(vim, [VisualDelete, VisualChange, VisualYank, VisualPaste]);
@ -43,6 +43,8 @@ pub fn visual_motion(motion: Motion, cx: &mut MutableAppContext) {
});
}
pub fn visual_object(_object: Object, _cx: &mut MutableAppContext) {}
pub fn change(_: &mut Workspace, _: &VisualChange, cx: &mut ViewContext<Workspace>) {
Vim::update(cx, |vim, cx| {
vim.update_active_editor(cx, |editor, cx| {
@ -274,7 +276,7 @@ pub fn paste(_: &mut Workspace, _: &VisualPaste, cx: &mut ViewContext<Workspace>
mod test {
use indoc::indoc;
use crate::{state::Mode, vim_test_context::VimTestContext};
use crate::{state::Mode, test_contexts::VimTestContext};
#[gpui::test]
async fn test_enter_visual_mode(cx: &mut gpui::TestAppContext) {

View file

@ -0,0 +1 @@
Fox Jumps! Over the lazy.

View file

@ -0,0 +1 @@
0,16

View file

@ -0,0 +1 @@
0,0

View file

@ -0,0 +1 @@
"Insert"

View file

@ -0,0 +1 @@
Fox Jumps! Over the lazy.

View file

@ -0,0 +1 @@
0,0

View file

@ -0,0 +1 @@
"Insert"

View file

@ -0,0 +1 @@
Fox Jumps! Over the lazy.

View file

@ -0,0 +1 @@
0,0

View file

@ -0,0 +1 @@
"Insert"

View file

@ -0,0 +1 @@
The quick brown? Over the lazy.

View file

@ -0,0 +1,12 @@
The quick
fox jumps over
the lazy dog
The-quick brown
fox-jumps over
the lazy dog

View file

@ -0,0 +1 @@
6,0

View file

@ -0,0 +1 @@
"Insert"

View file

@ -0,0 +1,12 @@
The quick brown
fox jumps over
the lazy dog
brown
fox-jumps over
the lazy dog

View file

@ -0,0 +1 @@
6,0

View file

@ -0,0 +1 @@
"Insert"

View file

@ -0,0 +1,12 @@
The quick brown
fox jumps over
the lazy dog
brown
fox-jumps over
the lazy dog

View file

@ -0,0 +1 @@
6,0

View file

@ -0,0 +1 @@
"Insert"

View file

@ -0,0 +1,12 @@
The quick brown
fox jumps over
the lazy dog
brown
fox-jumps over
the lazy dog

View file

@ -0,0 +1 @@
6,0

View file

@ -0,0 +1 @@
1,4

View file

@ -0,0 +1 @@
"Insert"

View file

@ -0,0 +1,12 @@
The quick brown
fox jumps over
the lazy dog
The-quick
fox-jumps over
the lazy dog

View file

@ -0,0 +1 @@
6,9

View file

@ -0,0 +1 @@
"Insert"

View file

@ -0,0 +1,12 @@
The quick brown
fox jumps over
the lazy dog
The-quick
fox-jumps over
the lazy dog

View file

@ -0,0 +1 @@
6,10

View file

@ -0,0 +1 @@
"Insert"

View file

@ -0,0 +1,9 @@
The quick brown
fox jumps over
the lazy dog
The-quick brown over
the lazy dog

View file

@ -0,0 +1 @@
6,15

View file

@ -0,0 +1 @@
"Insert"

View file

@ -0,0 +1 @@
"Insert"

View file

@ -0,0 +1,10 @@
The quick brown
fox jumps over
the lazy dog
The-quick brown
over
the lazy dog

View file

@ -0,0 +1 @@
7,0

View file

@ -0,0 +1 @@
"Insert"

View file

@ -0,0 +1,11 @@
The quick brown
fox jumps over
the lazy dog
The-quick brown
over
the lazy dog

View file

@ -0,0 +1 @@
8,0

View file

@ -0,0 +1 @@
"Insert"

View file

@ -0,0 +1,12 @@
The quick brown
fox jumps over
the lazy dog
The-quick brown
over
the lazy dog

View file

@ -0,0 +1 @@
9,0

View file

@ -0,0 +1 @@
"Insert"

View file

@ -0,0 +1,12 @@
The quick brown
fox jumps over
the lazy dog
The-quick brown
over
the lazy dog

View file

@ -0,0 +1,12 @@
The quick brown
fox over
the lazy dog
The-quick brown
fox-jumps over
the lazy dog

View file

@ -0,0 +1 @@
9,2

View file

@ -0,0 +1 @@
"Insert"

View file

@ -0,0 +1,11 @@
The quick brown
fox jumps over
the lazy dog
The-quick brown
fox-jumps over
the lazy dog

View file

@ -0,0 +1 @@
10,12

View file

@ -0,0 +1 @@
"Insert"

View file

@ -0,0 +1,11 @@
The quick brown
fox jumps over
the lazy dog
The-quick brown
fox-jumps over
the lazy dog

View file

@ -0,0 +1 @@
11,0

View file

@ -0,0 +1 @@
"Insert"

View file

@ -0,0 +1 @@
1,4

View file

@ -0,0 +1 @@
"Insert"

View file

@ -0,0 +1,12 @@
The quick brown
fox jumps
the lazy dog
The-quick brown
fox-jumps over
the lazy dog

View file

@ -0,0 +1 @@
1,9

View file

@ -0,0 +1 @@
"Insert"

View file

@ -0,0 +1,11 @@
The quick brown
fox jumps over
the lazy dog
The-quick brown
fox-jumps over
the lazy dog

View file

@ -0,0 +1 @@
2,12

View file

@ -0,0 +1 @@
0,10

View file

@ -0,0 +1 @@
"Insert"

View file

@ -0,0 +1,11 @@
The quick brown
fox jumps over
the lazy dog
The-quick brown
fox-jumps over
the lazy dog

View file

@ -0,0 +1 @@
3,0

View file

@ -0,0 +1 @@
"Insert"

View file

@ -0,0 +1,11 @@
The quick brown
fox jumps over
the lazy dog
The-quick brown
fox-jumps over
the lazy dog

View file

@ -0,0 +1 @@
4,0

View file

@ -0,0 +1 @@
"Insert"

View file

@ -0,0 +1,11 @@
The quick brown
fox jumps over
the lazy dog
-quick brown
fox-jumps over
the lazy dog

View file

@ -0,0 +1 @@
5,0

View file

@ -0,0 +1 @@
"Insert"

View file

@ -0,0 +1 @@
"Insert"

View file

@ -0,0 +1,12 @@
The quick brown
fox jumps over
the lazy dog
-quick brown
fox-jumps over
the lazy dog

View file

@ -0,0 +1 @@
6,0

View file

@ -0,0 +1 @@
"Insert"

View file

@ -0,0 +1,12 @@
The quick brown
fox jumps over
the lazy dog
Thequick brown
fox-jumps over
the lazy dog

View file

@ -0,0 +1 @@
6,3

View file

@ -0,0 +1 @@
"Insert"

Some files were not shown because too many files have changed in this diff Show more