Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement new textobject for indentation level #9843

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions book/src/textobjects.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ function or block of code.
| `w` | Word |
| `W` | WORD |
| `p` | Paragraph |
| `i` | Indentation level |
| `(`, `[`, `'`, etc. | Specified surround pairs |
| `m` | The closest surround pair |
| `f` | Function |
Expand Down
244 changes: 243 additions & 1 deletion helix-core/src/textobject.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
use std::cmp;
use std::fmt::Display;

use ropey::RopeSlice;
use tree_sitter::{Node, QueryCursor};

use crate::chars::{categorize_char, char_is_whitespace, CharCategory};
use crate::graphemes::{next_grapheme_boundary, prev_grapheme_boundary};
use crate::line_ending::rope_is_line_ending;
use crate::indent::indent_level_for_line;
use crate::line_ending::{get_line_ending, rope_is_line_ending};
use crate::movement::Direction;
use crate::syntax::LanguageConfiguration;
use crate::Range;
Expand Down Expand Up @@ -198,6 +200,92 @@ pub fn textobject_paragraph(
Range::new(anchor, head)
}

pub fn textobject_indentation_level(
slice: RopeSlice,
range: Range,
textobject: TextObject,
count: usize,
indent_width: usize,
tab_width: usize,
) -> Range {
let (mut line_start, mut line_end) = range.line_range(slice);
let mut min_indent: Option<usize> = None;

// Find the innermost indent represented by the current selection range.
// Range could be only on one line, so we need an inclusive range in the
// loop definition.
for i in line_start..=line_end {
let line = slice.line(i);

// Including empty lines leads to pathological behaviour, where having
// an empty line in a multi-line selection causes the entire buffer to
// be selected, which is not intuitively what we want.
if !rope_is_line_ending(line) {
let indent_level = indent_level_for_line(line, tab_width, indent_width);
min_indent = if let Some(prev_min_indent) = min_indent {
Some(cmp::min(indent_level, prev_min_indent))
} else {
Some(indent_level)
}
}
}

// It can happen that the selection consists of an empty line, so min_indent
// will be untouched, in which case we can skip the rest of the function
// and no-op.
if min_indent.is_none() {
return range;
}

let min_indent = min_indent.unwrap() + 1 - count;

// Traverse backwards until there are no more lines indented the same or
// greater, and extend the start of the range to it.
if line_start > 0 {
for line in slice.lines_at(line_start).reversed() {
let indent_level = indent_level_for_line(line, tab_width, indent_width);
let empty_line = rope_is_line_ending(line);
if (min_indent > 0 && indent_level >= min_indent)
|| (min_indent == 0 && !empty_line)
|| (textobject == TextObject::Around && empty_line)
{
line_start -= 1;
} else {
break;
}
}
}

// Traverse forwards until there are no more lines indented the same or
// greater, and extend the end of the range to it.
if line_end < slice.len_lines() {
for line in slice.lines_at(line_end + 1) {
let indent_level = indent_level_for_line(line, tab_width, indent_width);
let empty_line = rope_is_line_ending(line);
if (min_indent > 0 && indent_level >= min_indent)
|| (min_indent == 0 && !empty_line)
|| (textobject == TextObject::Around && empty_line)
{
line_end += 1;
} else {
break;
}
}
}

let new_char_start = slice.line_to_char(line_start);
let new_line_end_slice = slice.line(line_end);
let mut new_char_end = new_line_end_slice.chars().count() + slice.line_to_char(line_end);

// Unless the end of the new range is to the end of the buffer, we want to
// trim the final line ending from the selection.
if let Some(line_ending) = get_line_ending(&new_line_end_slice) {
new_char_end = new_char_end.saturating_sub(line_ending.len_chars());
}

Range::new(new_char_start, new_char_end).with_direction(range.direction())
}

pub fn textobject_pair_surround(
syntax: Option<&Syntax>,
slice: RopeSlice,
Expand Down Expand Up @@ -498,6 +586,160 @@ mod test {
}
}

#[test]
fn test_textobject_indentation_level_inside() {
let tests = [
("#[|]#", "#[|]#", 1),
(
"unindented\n\t#[i|]#ndented once",
"unindented\n#[\tindented once|]#",
1,
),
(
"unindented\n\t#[i|]#ndented once\n",
"unindented\n#[\tindented once|]#\n",
1,
),
(
"unindented\n\t#[|in]#dented once\n",
"unindented\n#[|\tindented once]#\n",
1,
),
(
"#[u|]#nindented\n\tindented once\n",
"#[unindented\n\tindented once|]#\n",
1,
),
(
"unindented\n\n\t#[i|]#ndented once and separated\n",
"unindented\n\n#[\tindented once and separated|]#\n",
1,
),
(
"#[u|]#nindented\n\n\tindented once and separated\n",
"#[unindented|]#\n\n\tindented once and separated\n",
1,
),
(
"unindented\n\nunindented again\n\tindented #[once|]#\nunindented one more time",
"unindented\n\nunindented again\n#[\tindented once|]#\nunindented one more time",
1,
),
(
"unindented\n\nunindented #[again\n\tindented|]# once\nunindented one more time\n",
"unindented\n\n#[unindented again\n\tindented once\nunindented one more time|]#\n",
1,
),
(
"unindented\n\tindented #[once\n\n\tindented once|]# and separated\n\tindented once again\nunindented one more time\n",
"unindented\n#[\tindented once\n\n\tindented once and separated\n\tindented once again|]#\nunindented one more time\n",
1,
),
(
"unindented\n\tindented once\n#[\n|]#\tindented once and separated\n\tindented once again\nunindented one more time\n",
"unindented\n\tindented once\n#[\n|]#\tindented once and separated\n\tindented once again\nunindented one more time\n",
1,
),
(
"unindented\n\tindented once\n\t\tindented #[twice|]#\n\tindented once again\nunindented\n",
"unindented\n#[\tindented once\n\t\tindented twice\n\tindented once again|]#\nunindented\n",
2,
),
(
"unindented\n\tindented once\n\t\tindented #[twice|]#\n\tindented once again\nunindented\n",
"#[unindented\n\tindented once\n\t\tindented twice\n\tindented once again\nunindented|]#\n",
3,
),
];

for (before, expected, count) in tests {
let (s, selection) = crate::test::print(before);
let text = Rope::from(s.as_str());
let selection = selection.transform(|r| {
textobject_indentation_level(text.slice(..), r, TextObject::Inside, count, 4, 4)
});
let actual = crate::test::plain(s.as_ref(), &selection);
assert_eq!(actual, expected, "\nbefore: `{:?}`", before);
}
}

#[test]
fn test_textobject_indentation_level_around() {
let tests = [
("#[|]#", "#[|]#", 1),
(
"unindented\n\t#[i|]#ndented once",
"unindented\n#[\tindented once|]#",
1,
),
(
"unindented\n\t#[i|]#ndented once\n",
"unindented\n#[\tindented once\n|]#",
1,
),
(
"unindented\n\t#[|in]#dented once\n",
"unindented\n#[|\tindented once\n]#",
1,
),
(
"#[u|]#nindented\n\tindented once\n",
"#[unindented\n\tindented once\n|]#",
1,
),
(
"unindented\n\n\t#[i|]#ndented once and separated\n",
"unindented\n#[\n\tindented once and separated\n|]#",
1,
),
(
"#[u|]#nindented\n\n\tindented once and separated\n",
"#[unindented\n\n\tindented once and separated\n|]#",
1,
),
(
"unindented\n\nunindented again\n\tindented #[once|]#\nunindented one more time",
"unindented\n\nunindented again\n#[\tindented once|]#\nunindented one more time",
1,
),
(
"unindented\n\nunindented #[again\n\tindented|]# once\nunindented one more time\n",
"#[unindented\n\nunindented again\n\tindented once\nunindented one more time\n|]#",
1,
),
(
"unindented\n\tindented #[once\n\n\tindented once|]# and separated\n\tindented once again\nunindented one more time\n",
"unindented\n#[\tindented once\n\n\tindented once and separated\n\tindented once again|]#\nunindented one more time\n",
1,
),
(
"unindented\n\tindented once\n#[\n|]#\tindented once and separated\n\tindented once again\nunindented one more time\n",
"unindented\n\tindented once\n#[\n|]#\tindented once and separated\n\tindented once again\nunindented one more time\n",
1,
),
(
"unindented\n\tindented once\n\t\tindented #[twice|]#\n\tindented once again\nunindented\n",
"unindented\n#[\tindented once\n\t\tindented twice\n\tindented once again|]#\nunindented\n",
2,
),
(
"unindented\n\tindented once\n\t\tindented #[twice|]#\n\tindented once again\nunindented\n",
"#[unindented\n\tindented once\n\t\tindented twice\n\tindented once again\nunindented\n|]#",
3,
),
];

for (before, expected, count) in tests {
let (s, selection) = crate::test::print(before);
let text = Rope::from(s.as_str());
let selection = selection.transform(|r| {
textobject_indentation_level(text.slice(..), r, TextObject::Around, count, 4, 4)
});
let actual = crate::test::plain(s.as_ref(), &selection);
assert_eq!(actual, expected, "\nbefore: `{:?}`", before);
}
}

#[test]
fn test_textobject_surround() {
// (text, [(cursor position, textobject, final range, surround char, count), ...])
Expand Down
9 changes: 9 additions & 0 deletions helix-term/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5400,6 +5400,14 @@ fn select_textobject(cx: &mut Context, objtype: textobject::TextObject) {
'T' => textobject_treesitter("test", range),
'e' => textobject_treesitter("entry", range),
'p' => textobject::textobject_paragraph(text, range, objtype, count),
'i' => textobject::textobject_indentation_level(
text,
range,
objtype,
count,
doc.indent_width(),
doc.tab_width(),
),
'm' => textobject::textobject_pair_surround_closest(
doc.syntax(),
text,
Expand Down Expand Up @@ -5435,6 +5443,7 @@ fn select_textobject(cx: &mut Context, objtype: textobject::TextObject) {
("w", "Word"),
("W", "WORD"),
("p", "Paragraph"),
("i", "Indentation level"),
("t", "Type definition (tree-sitter)"),
("f", "Function (tree-sitter)"),
("a", "Argument/parameter (tree-sitter)"),
Expand Down
Loading