vim: Add support for ap
and ip
paragraph text objects (#7687)
This PR adds support for `ap`/`ip` text objects in Vim mode and allows users to perform paragraph-based operations. Cases where compatibility with Neovim's behavior is checked, cases where there are known differences in behavior with Neovim (cases where the landing position is other than the beginning of the line), and cases where the Neovim behavior in the test suite seems strange are separated in the test code so that they can be identified. Release Notes: - Added support for `ap` and `ip` paragraph text objects in Vim mode ([#7359](https://github.com/zed-industries/zed/issues/7359)).
This commit is contained in:
parent
b742db65fe
commit
d223fe446d
8 changed files with 1265 additions and 11 deletions
|
@ -1,8 +1,12 @@
|
|||
use crate::{motion::Motion, object::Object, utils::copy_selections_content, Vim};
|
||||
use collections::{HashMap, HashSet};
|
||||
use editor::{display_map::ToDisplayPoint, scroll::Autoscroll, Bias};
|
||||
use editor::{
|
||||
display_map::{DisplaySnapshot, ToDisplayPoint},
|
||||
scroll::Autoscroll,
|
||||
Bias, DisplayPoint,
|
||||
};
|
||||
use gpui::WindowContext;
|
||||
use language::Point;
|
||||
use language::{Point, Selection};
|
||||
|
||||
pub fn delete_motion(vim: &mut Vim, motion: Motion, times: Option<usize>, cx: &mut WindowContext) {
|
||||
vim.stop_recording();
|
||||
|
@ -72,6 +76,14 @@ pub fn delete_object(vim: &mut Vim, object: Object, around: bool, cx: &mut Windo
|
|||
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 mut move_selection_start_to_previous_line =
|
||||
|map: &DisplaySnapshot, selection: &mut Selection<DisplayPoint>| {
|
||||
let start = selection.start.to_offset(map, Bias::Left);
|
||||
if selection.start.row() > 0 {
|
||||
should_move_to_start.insert(selection.id);
|
||||
selection.start = (start - '\n'.len_utf8()).to_display_point(map);
|
||||
}
|
||||
};
|
||||
let contains_only_newlines = map
|
||||
.chars_at(selection.start)
|
||||
.take_while(|(_, p)| p < &selection.end)
|
||||
|
@ -88,12 +100,23 @@ pub fn delete_object(vim: &mut Vim, object: Object, around: bool, cx: &mut Windo
|
|||
// 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);
|
||||
move_selection_end_to_next_line(map, selection);
|
||||
} else {
|
||||
move_selection_start_to_previous_line(map, selection);
|
||||
}
|
||||
}
|
||||
|
||||
// Does post-processing for the trailing newline and EOF
|
||||
// when not cancelled.
|
||||
let cancelled = around && selection.start == selection.end;
|
||||
if object == Object::Paragraph && !cancelled {
|
||||
// EOF check should be done before including a trailing newline.
|
||||
if ends_at_eof(map, selection) {
|
||||
move_selection_start_to_previous_line(map, selection);
|
||||
}
|
||||
|
||||
if end_at_newline {
|
||||
move_selection_end_to_next_line(map, selection);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
@ -117,6 +140,15 @@ pub fn delete_object(vim: &mut Vim, object: Object, around: bool, cx: &mut Windo
|
|||
});
|
||||
}
|
||||
|
||||
fn move_selection_end_to_next_line(map: &DisplaySnapshot, selection: &mut Selection<DisplayPoint>) {
|
||||
let end = selection.end.to_offset(map, Bias::Left);
|
||||
selection.end = (end + '\n'.len_utf8()).to_display_point(map);
|
||||
}
|
||||
|
||||
fn ends_at_eof(map: &DisplaySnapshot, selection: &mut Selection<DisplayPoint>) -> bool {
|
||||
selection.end.to_point(map) == map.buffer_snapshot.max_point()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use indoc::indoc;
|
||||
|
|
|
@ -10,7 +10,7 @@ use editor::{
|
|||
Bias, DisplayPoint,
|
||||
};
|
||||
use gpui::{actions, impl_actions, ViewContext, WindowContext};
|
||||
use language::{char_kind, BufferSnapshot, CharKind, Selection};
|
||||
use language::{char_kind, BufferSnapshot, CharKind, Point, Selection};
|
||||
use serde::Deserialize;
|
||||
use workspace::Workspace;
|
||||
|
||||
|
@ -18,6 +18,7 @@ use workspace::Workspace;
|
|||
pub enum Object {
|
||||
Word { ignore_punctuation: bool },
|
||||
Sentence,
|
||||
Paragraph,
|
||||
Quotes,
|
||||
BackQuotes,
|
||||
DoubleQuotes,
|
||||
|
@ -43,6 +44,7 @@ actions!(
|
|||
vim,
|
||||
[
|
||||
Sentence,
|
||||
Paragraph,
|
||||
Quotes,
|
||||
BackQuotes,
|
||||
DoubleQuotes,
|
||||
|
@ -65,6 +67,8 @@ pub fn register(workspace: &mut Workspace, _: &mut ViewContext<Workspace>) {
|
|||
workspace.register_action(|_: &mut Workspace, _: &Tag, cx: _| object(Object::Tag, cx));
|
||||
workspace
|
||||
.register_action(|_: &mut Workspace, _: &Sentence, cx: _| object(Object::Sentence, cx));
|
||||
workspace
|
||||
.register_action(|_: &mut Workspace, _: &Paragraph, cx: _| object(Object::Paragraph, cx));
|
||||
workspace.register_action(|_: &mut Workspace, _: &Quotes, cx: _| object(Object::Quotes, cx));
|
||||
workspace
|
||||
.register_action(|_: &mut Workspace, _: &BackQuotes, cx: _| object(Object::BackQuotes, cx));
|
||||
|
@ -109,6 +113,7 @@ impl Object {
|
|||
| Object::VerticalBars
|
||||
| Object::DoubleQuotes => false,
|
||||
Object::Sentence
|
||||
| Object::Paragraph
|
||||
| Object::Parentheses
|
||||
| Object::Tag
|
||||
| Object::AngleBrackets
|
||||
|
@ -120,7 +125,7 @@ impl Object {
|
|||
|
||||
pub fn always_expands_both_ways(self) -> bool {
|
||||
match self {
|
||||
Object::Word { .. } | Object::Sentence | Object::Argument => false,
|
||||
Object::Word { .. } | Object::Sentence | Object::Paragraph | Object::Argument => false,
|
||||
Object::Quotes
|
||||
| Object::BackQuotes
|
||||
| Object::DoubleQuotes
|
||||
|
@ -153,6 +158,7 @@ impl Object {
|
|||
| Object::VerticalBars
|
||||
| Object::Tag
|
||||
| Object::Argument => Mode::Visual,
|
||||
Object::Paragraph => Mode::VisualLine,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -171,6 +177,7 @@ impl Object {
|
|||
}
|
||||
}
|
||||
Object::Sentence => sentence(map, relative_to, around),
|
||||
Object::Paragraph => paragraph(map, relative_to, around),
|
||||
Object::Quotes => {
|
||||
surrounding_markers(map, relative_to, around, self.is_multiline(), '\'', '\'')
|
||||
}
|
||||
|
@ -684,6 +691,99 @@ fn expand_to_include_whitespace(
|
|||
range
|
||||
}
|
||||
|
||||
/// If not `around` (i.e. inner), returns a range that surrounds the paragraph
|
||||
/// where `relative_to` is in. If `around`, principally returns the range ending
|
||||
/// at the end of the next paragraph.
|
||||
///
|
||||
/// Here, the "paragraph" is defined as a block of non-blank lines or a block of
|
||||
/// blank lines. If the paragraph ends with a trailing newline (i.e. not with
|
||||
/// EOF), the returned range ends at the trailing newline of the paragraph (i.e.
|
||||
/// the trailing newline is not subject to subsequent operations).
|
||||
///
|
||||
/// Edge cases:
|
||||
/// - If `around` and if the current paragraph is the last paragraph of the
|
||||
/// file and is blank, then the selection results in an error.
|
||||
/// - If `around` and if the current paragraph is the last paragraph of the
|
||||
/// file and is not blank, then the returned range starts at the start of the
|
||||
/// previous paragraph, if it exists.
|
||||
fn paragraph(
|
||||
map: &DisplaySnapshot,
|
||||
relative_to: DisplayPoint,
|
||||
around: bool,
|
||||
) -> Option<Range<DisplayPoint>> {
|
||||
let mut paragraph_start = start_of_paragraph(map, relative_to);
|
||||
let mut paragraph_end = end_of_paragraph(map, relative_to);
|
||||
|
||||
let paragraph_end_row = paragraph_end.row();
|
||||
let paragraph_ends_with_eof = paragraph_end_row == map.max_point().row();
|
||||
let point = relative_to.to_point(map);
|
||||
let current_line_is_empty = map.buffer_snapshot.is_line_blank(point.row);
|
||||
|
||||
if around {
|
||||
if paragraph_ends_with_eof {
|
||||
if current_line_is_empty {
|
||||
return None;
|
||||
}
|
||||
|
||||
let paragraph_start_row = paragraph_start.row();
|
||||
if paragraph_start_row != 0 {
|
||||
let previous_paragraph_last_line_start =
|
||||
Point::new(paragraph_start_row - 1, 0).to_display_point(map);
|
||||
paragraph_start = start_of_paragraph(map, previous_paragraph_last_line_start);
|
||||
}
|
||||
} else {
|
||||
let next_paragraph_start = Point::new(paragraph_end_row + 1, 0).to_display_point(map);
|
||||
paragraph_end = end_of_paragraph(map, next_paragraph_start);
|
||||
}
|
||||
}
|
||||
|
||||
let range = paragraph_start..paragraph_end;
|
||||
Some(range)
|
||||
}
|
||||
|
||||
/// Returns a position of the start of the current paragraph, where a paragraph
|
||||
/// is defined as a run of non-blank lines or a run of blank lines.
|
||||
pub fn start_of_paragraph(map: &DisplaySnapshot, display_point: DisplayPoint) -> DisplayPoint {
|
||||
let point = display_point.to_point(map);
|
||||
if point.row == 0 {
|
||||
return DisplayPoint::zero();
|
||||
}
|
||||
|
||||
let is_current_line_blank = map.buffer_snapshot.is_line_blank(point.row);
|
||||
|
||||
for row in (0..point.row).rev() {
|
||||
let blank = map.buffer_snapshot.is_line_blank(row);
|
||||
if blank != is_current_line_blank {
|
||||
return Point::new(row + 1, 0).to_display_point(map);
|
||||
}
|
||||
}
|
||||
|
||||
DisplayPoint::zero()
|
||||
}
|
||||
|
||||
/// Returns a position of the end of the current paragraph, where a paragraph
|
||||
/// is defined as a run of non-blank lines or a run of blank lines.
|
||||
/// The trailing newline is excluded from the paragraph.
|
||||
pub fn end_of_paragraph(map: &DisplaySnapshot, display_point: DisplayPoint) -> DisplayPoint {
|
||||
let point = display_point.to_point(map);
|
||||
if point.row == map.max_buffer_row() {
|
||||
return map.max_point();
|
||||
}
|
||||
|
||||
let is_current_line_blank = map.buffer_snapshot.is_line_blank(point.row);
|
||||
|
||||
for row in point.row + 1..map.max_buffer_row() + 1 {
|
||||
let blank = map.buffer_snapshot.is_line_blank(row);
|
||||
if blank != is_current_line_blank {
|
||||
let previous_row = row - 1;
|
||||
return Point::new(previous_row, map.buffer_snapshot.line_len(previous_row))
|
||||
.to_display_point(map);
|
||||
}
|
||||
}
|
||||
|
||||
map.max_point()
|
||||
}
|
||||
|
||||
fn surrounding_markers(
|
||||
map: &DisplaySnapshot,
|
||||
relative_to: DisplayPoint,
|
||||
|
@ -1047,6 +1147,168 @@ mod test {
|
|||
}
|
||||
}
|
||||
|
||||
const PARAGRAPH_EXAMPLES: &[&'static str] = &[
|
||||
// Single line
|
||||
"ˇThe quick brown fox jumpˇs over the lazy dogˇ.ˇ",
|
||||
// Multiple lines without empty lines
|
||||
indoc! {"
|
||||
ˇThe quick brownˇ
|
||||
ˇfox jumps overˇ
|
||||
the lazy dog.ˇ
|
||||
"},
|
||||
// Heading blank paragraph and trailing normal paragraph
|
||||
indoc! {"
|
||||
ˇ
|
||||
ˇ
|
||||
ˇThe quick brown fox jumps
|
||||
ˇover the lazy dog.
|
||||
ˇ
|
||||
ˇ
|
||||
ˇThe quick brown fox jumpsˇ
|
||||
ˇover the lazy dog.ˇ
|
||||
"},
|
||||
// Inserted blank paragraph and trailing blank paragraph
|
||||
indoc! {"
|
||||
ˇThe quick brown fox jumps
|
||||
ˇover the lazy dog.
|
||||
ˇ
|
||||
ˇ
|
||||
ˇ
|
||||
ˇThe quick brown fox jumpsˇ
|
||||
ˇover the lazy dog.ˇ
|
||||
ˇ
|
||||
ˇ
|
||||
ˇ
|
||||
"},
|
||||
// "Blank" paragraph with whitespace characters
|
||||
indoc! {"
|
||||
ˇThe quick brown fox jumps
|
||||
over the lazy dog.
|
||||
|
||||
ˇ \t
|
||||
|
||||
ˇThe quick brown fox jumps
|
||||
over the lazy dog.ˇ
|
||||
ˇ
|
||||
ˇ \t
|
||||
\t \t
|
||||
"},
|
||||
// Single line "paragraphs", where selection size might be zero.
|
||||
indoc! {"
|
||||
ˇThe quick brown fox jumps over the lazy dog.
|
||||
ˇ
|
||||
ˇThe quick brown fox jumpˇs over the lazy dog.ˇ
|
||||
ˇ
|
||||
"},
|
||||
];
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_change_paragraph_object(cx: &mut gpui::TestAppContext) {
|
||||
let mut cx = NeovimBackedTestContext::new(cx).await;
|
||||
|
||||
for paragraph_example in PARAGRAPH_EXAMPLES {
|
||||
cx.assert_binding_matches_all(["c", "i", "p"], paragraph_example)
|
||||
.await;
|
||||
cx.assert_binding_matches_all(["c", "a", "p"], paragraph_example)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_delete_paragraph_object(cx: &mut gpui::TestAppContext) {
|
||||
let mut cx = NeovimBackedTestContext::new(cx).await;
|
||||
|
||||
for paragraph_example in PARAGRAPH_EXAMPLES {
|
||||
cx.assert_binding_matches_all(["d", "i", "p"], paragraph_example)
|
||||
.await;
|
||||
cx.assert_binding_matches_all(["d", "a", "p"], paragraph_example)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_paragraph_object_with_landing_positions_not_at_beginning_of_line(
|
||||
cx: &mut gpui::TestAppContext,
|
||||
) {
|
||||
// Landing position not at the beginning of the line
|
||||
const PARAGRAPH_LANDING_POSITION_EXAMPLE: &'static str = indoc! {"
|
||||
The quick brown fox jumpsˇ
|
||||
over the lazy dog.ˇ
|
||||
ˇ ˇ\tˇ
|
||||
ˇ ˇ
|
||||
ˇ\tˇ ˇ\tˇ
|
||||
ˇThe quick brown fox jumpsˇ
|
||||
ˇover the lazy dog.ˇ
|
||||
ˇ ˇ\tˇ
|
||||
ˇ
|
||||
ˇ ˇ\tˇ
|
||||
ˇ\tˇ ˇ\tˇ
|
||||
"};
|
||||
|
||||
let mut cx = NeovimBackedTestContext::new(cx).await;
|
||||
|
||||
cx.assert_binding_matches_all_exempted(
|
||||
["c", "i", "p"],
|
||||
PARAGRAPH_LANDING_POSITION_EXAMPLE,
|
||||
ExemptionFeatures::IncorrectLandingPosition,
|
||||
)
|
||||
.await;
|
||||
cx.assert_binding_matches_all_exempted(
|
||||
["c", "a", "p"],
|
||||
PARAGRAPH_LANDING_POSITION_EXAMPLE,
|
||||
ExemptionFeatures::IncorrectLandingPosition,
|
||||
)
|
||||
.await;
|
||||
cx.assert_binding_matches_all_exempted(
|
||||
["d", "i", "p"],
|
||||
PARAGRAPH_LANDING_POSITION_EXAMPLE,
|
||||
ExemptionFeatures::IncorrectLandingPosition,
|
||||
)
|
||||
.await;
|
||||
cx.assert_binding_matches_all_exempted(
|
||||
["d", "a", "p"],
|
||||
PARAGRAPH_LANDING_POSITION_EXAMPLE,
|
||||
ExemptionFeatures::IncorrectLandingPosition,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_visual_paragraph_object(cx: &mut gpui::TestAppContext) {
|
||||
let mut cx = NeovimBackedTestContext::new(cx).await;
|
||||
|
||||
const EXAMPLES: &[&'static str] = &[
|
||||
indoc! {"
|
||||
ˇThe quick brown
|
||||
fox jumps over
|
||||
the lazy dog.
|
||||
"},
|
||||
indoc! {"
|
||||
ˇ
|
||||
|
||||
ˇThe quick brown fox jumps
|
||||
over the lazy dog.
|
||||
ˇ
|
||||
|
||||
ˇThe quick brown fox jumps
|
||||
over the lazy dog.
|
||||
"},
|
||||
indoc! {"
|
||||
ˇThe quick brown fox jumps over the lazy dog.
|
||||
ˇ
|
||||
ˇThe quick brown fox jumps over the lazy dog.
|
||||
|
||||
"},
|
||||
];
|
||||
|
||||
for paragraph_example in EXAMPLES {
|
||||
cx.assert_binding_matches_all(["v", "i", "p"], paragraph_example)
|
||||
.await;
|
||||
cx.assert_binding_matches_all(["v", "a", "p"], paragraph_example)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
// Test string with "`" for opening surrounders and "'" for closing surrounders
|
||||
const SURROUNDING_MARKER_STRING: &str = indoc! {"
|
||||
ˇTh'ˇe ˇ`ˇ'ˇquˇi`ˇck broˇ'wn`
|
||||
|
|
|
@ -9,7 +9,7 @@ use editor::{
|
|||
Bias, DisplayPoint, Editor,
|
||||
};
|
||||
use gpui::{actions, ViewContext, WindowContext};
|
||||
use language::{Selection, SelectionGoal};
|
||||
use language::{Point, Selection, SelectionGoal};
|
||||
use workspace::Workspace;
|
||||
|
||||
use crate::{
|
||||
|
@ -279,6 +279,25 @@ pub fn visual_object(object: Object, cx: &mut WindowContext) {
|
|||
selection.end = range.end;
|
||||
}
|
||||
}
|
||||
|
||||
// In the visual selection result of a paragraph object, the cursor is
|
||||
// placed at the start of the last line. And in the visual mode, the
|
||||
// selection end is located after the end character. So, adjustment of
|
||||
// selection end is needed.
|
||||
//
|
||||
// We don't do this adjustment for a one-line blank paragraph since the
|
||||
// trailing newline is included in its selection from the beginning.
|
||||
if object == Object::Paragraph && range.start != range.end {
|
||||
let row_of_selection_end_line = selection.end.to_point(map).row;
|
||||
let new_selection_end =
|
||||
if map.buffer_snapshot.line_len(row_of_selection_end_line) == 0
|
||||
{
|
||||
Point::new(row_of_selection_end_line + 1, 0)
|
||||
} else {
|
||||
Point::new(row_of_selection_end_line, 1)
|
||||
};
|
||||
selection.end = new_selection_end.to_display_point(map);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue