Skip to content

Commit da87bd8

Browse files
EpocSquadroniniw
authored andcommitted
Implement new textobject for indentation level This implements a textobject corresponding to the current indentation level of the selection(s). It is only implemented for "match mode" bound to `i` and takes a count, where count extends the selection leftwards additional indentation levels. Inside and Around versions are distinguished by whether they tolerate empty lines.
1 parent 1f3012d commit da87bd8

File tree

3 files changed

+253
-1
lines changed

3 files changed

+253
-1
lines changed

book/src/textobjects.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ function or block of code.
1616
| `w` | Word |
1717
| `W` | WORD |
1818
| `p` | Paragraph |
19+
| `i` | Indentation level |
1920
| `(`, `[`, `'`, etc. | Specified surround pairs |
2021
| `m` | The closest surround pair |
2122
| `f` | Function |

helix-core/src/textobject.rs

Lines changed: 243 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1+
use std::cmp;
12
use std::fmt::Display;
23

34
use ropey::RopeSlice;
45

56
use crate::chars::{categorize_char, char_is_whitespace, CharCategory};
67
use crate::graphemes::{next_grapheme_boundary, prev_grapheme_boundary};
7-
use crate::line_ending::rope_is_line_ending;
8+
use crate::indent::indent_level_for_line;
9+
use crate::line_ending::{get_line_ending, rope_is_line_ending};
810
use crate::movement::Direction;
911
use crate::syntax;
1012
use crate::Range;
@@ -197,6 +199,92 @@ pub fn textobject_paragraph(
197199
Range::new(anchor, head)
198200
}
199201

202+
pub fn textobject_indentation_level(
203+
slice: RopeSlice,
204+
range: Range,
205+
textobject: TextObject,
206+
count: usize,
207+
indent_width: usize,
208+
tab_width: usize,
209+
) -> Range {
210+
let (mut line_start, mut line_end) = range.line_range(slice);
211+
let mut min_indent: Option<usize> = None;
212+
213+
// Find the innermost indent represented by the current selection range.
214+
// Range could be only on one line, so we need an inclusive range in the
215+
// loop definition.
216+
for i in line_start..=line_end {
217+
let line = slice.line(i);
218+
219+
// Including empty lines leads to pathological behaviour, where having
220+
// an empty line in a multi-line selection causes the entire buffer to
221+
// be selected, which is not intuitively what we want.
222+
if !rope_is_line_ending(line) {
223+
let indent_level = indent_level_for_line(line, tab_width, indent_width);
224+
min_indent = if let Some(prev_min_indent) = min_indent {
225+
Some(cmp::min(indent_level, prev_min_indent))
226+
} else {
227+
Some(indent_level)
228+
}
229+
}
230+
}
231+
232+
// It can happen that the selection consists of an empty line, so min_indent
233+
// will be untouched, in which case we can skip the rest of the function
234+
// and no-op.
235+
if min_indent.is_none() {
236+
return range;
237+
}
238+
239+
let min_indent = min_indent.unwrap() + 1 - count;
240+
241+
// Traverse backwards until there are no more lines indented the same or
242+
// greater, and extend the start of the range to it.
243+
if line_start > 0 {
244+
for line in slice.lines_at(line_start).reversed() {
245+
let indent_level = indent_level_for_line(line, tab_width, indent_width);
246+
let empty_line = rope_is_line_ending(line);
247+
if (min_indent > 0 && indent_level >= min_indent)
248+
|| (min_indent == 0 && !empty_line)
249+
|| (textobject == TextObject::Around && empty_line)
250+
{
251+
line_start -= 1;
252+
} else {
253+
break;
254+
}
255+
}
256+
}
257+
258+
// Traverse forwards until there are no more lines indented the same or
259+
// greater, and extend the end of the range to it.
260+
if line_end < slice.len_lines() {
261+
for line in slice.lines_at(line_end + 1) {
262+
let indent_level = indent_level_for_line(line, tab_width, indent_width);
263+
let empty_line = rope_is_line_ending(line);
264+
if (min_indent > 0 && indent_level >= min_indent)
265+
|| (min_indent == 0 && !empty_line)
266+
|| (textobject == TextObject::Around && empty_line)
267+
{
268+
line_end += 1;
269+
} else {
270+
break;
271+
}
272+
}
273+
}
274+
275+
let new_char_start = slice.line_to_char(line_start);
276+
let new_line_end_slice = slice.line(line_end);
277+
let mut new_char_end = new_line_end_slice.chars().count() + slice.line_to_char(line_end);
278+
279+
// Unless the end of the new range is to the end of the buffer, we want to
280+
// trim the final line ending from the selection.
281+
if let Some(line_ending) = get_line_ending(&new_line_end_slice) {
282+
new_char_end = new_char_end.saturating_sub(line_ending.len_chars());
283+
}
284+
285+
Range::new(new_char_start, new_char_end).with_direction(range.direction())
286+
}
287+
200288
pub fn textobject_pair_surround(
201289
syntax: Option<&Syntax>,
202290
slice: RopeSlice,
@@ -497,6 +585,160 @@ mod test {
497585
}
498586
}
499587

588+
#[test]
589+
fn test_textobject_indentation_level_inside() {
590+
let tests = [
591+
("#[|]#", "#[|]#", 1),
592+
(
593+
"unindented\n\t#[i|]#ndented once",
594+
"unindented\n#[\tindented once|]#",
595+
1,
596+
),
597+
(
598+
"unindented\n\t#[i|]#ndented once\n",
599+
"unindented\n#[\tindented once|]#\n",
600+
1,
601+
),
602+
(
603+
"unindented\n\t#[|in]#dented once\n",
604+
"unindented\n#[|\tindented once]#\n",
605+
1,
606+
),
607+
(
608+
"#[u|]#nindented\n\tindented once\n",
609+
"#[unindented\n\tindented once|]#\n",
610+
1,
611+
),
612+
(
613+
"unindented\n\n\t#[i|]#ndented once and separated\n",
614+
"unindented\n\n#[\tindented once and separated|]#\n",
615+
1,
616+
),
617+
(
618+
"#[u|]#nindented\n\n\tindented once and separated\n",
619+
"#[unindented|]#\n\n\tindented once and separated\n",
620+
1,
621+
),
622+
(
623+
"unindented\n\nunindented again\n\tindented #[once|]#\nunindented one more time",
624+
"unindented\n\nunindented again\n#[\tindented once|]#\nunindented one more time",
625+
1,
626+
),
627+
(
628+
"unindented\n\nunindented #[again\n\tindented|]# once\nunindented one more time\n",
629+
"unindented\n\n#[unindented again\n\tindented once\nunindented one more time|]#\n",
630+
1,
631+
),
632+
(
633+
"unindented\n\tindented #[once\n\n\tindented once|]# and separated\n\tindented once again\nunindented one more time\n",
634+
"unindented\n#[\tindented once\n\n\tindented once and separated\n\tindented once again|]#\nunindented one more time\n",
635+
1,
636+
),
637+
(
638+
"unindented\n\tindented once\n#[\n|]#\tindented once and separated\n\tindented once again\nunindented one more time\n",
639+
"unindented\n\tindented once\n#[\n|]#\tindented once and separated\n\tindented once again\nunindented one more time\n",
640+
1,
641+
),
642+
(
643+
"unindented\n\tindented once\n\t\tindented #[twice|]#\n\tindented once again\nunindented\n",
644+
"unindented\n#[\tindented once\n\t\tindented twice\n\tindented once again|]#\nunindented\n",
645+
2,
646+
),
647+
(
648+
"unindented\n\tindented once\n\t\tindented #[twice|]#\n\tindented once again\nunindented\n",
649+
"#[unindented\n\tindented once\n\t\tindented twice\n\tindented once again\nunindented|]#\n",
650+
3,
651+
),
652+
];
653+
654+
for (before, expected, count) in tests {
655+
let (s, selection) = crate::test::print(before);
656+
let text = Rope::from(s.as_str());
657+
let selection = selection.transform(|r| {
658+
textobject_indentation_level(text.slice(..), r, TextObject::Inside, count, 4, 4)
659+
});
660+
let actual = crate::test::plain(s.as_ref(), &selection);
661+
assert_eq!(actual, expected, "\nbefore: `{:?}`", before);
662+
}
663+
}
664+
665+
#[test]
666+
fn test_textobject_indentation_level_around() {
667+
let tests = [
668+
("#[|]#", "#[|]#", 1),
669+
(
670+
"unindented\n\t#[i|]#ndented once",
671+
"unindented\n#[\tindented once|]#",
672+
1,
673+
),
674+
(
675+
"unindented\n\t#[i|]#ndented once\n",
676+
"unindented\n#[\tindented once\n|]#",
677+
1,
678+
),
679+
(
680+
"unindented\n\t#[|in]#dented once\n",
681+
"unindented\n#[|\tindented once\n]#",
682+
1,
683+
),
684+
(
685+
"#[u|]#nindented\n\tindented once\n",
686+
"#[unindented\n\tindented once\n|]#",
687+
1,
688+
),
689+
(
690+
"unindented\n\n\t#[i|]#ndented once and separated\n",
691+
"unindented\n#[\n\tindented once and separated\n|]#",
692+
1,
693+
),
694+
(
695+
"#[u|]#nindented\n\n\tindented once and separated\n",
696+
"#[unindented\n\n\tindented once and separated\n|]#",
697+
1,
698+
),
699+
(
700+
"unindented\n\nunindented again\n\tindented #[once|]#\nunindented one more time",
701+
"unindented\n\nunindented again\n#[\tindented once|]#\nunindented one more time",
702+
1,
703+
),
704+
(
705+
"unindented\n\nunindented #[again\n\tindented|]# once\nunindented one more time\n",
706+
"#[unindented\n\nunindented again\n\tindented once\nunindented one more time\n|]#",
707+
1,
708+
),
709+
(
710+
"unindented\n\tindented #[once\n\n\tindented once|]# and separated\n\tindented once again\nunindented one more time\n",
711+
"unindented\n#[\tindented once\n\n\tindented once and separated\n\tindented once again|]#\nunindented one more time\n",
712+
1,
713+
),
714+
(
715+
"unindented\n\tindented once\n#[\n|]#\tindented once and separated\n\tindented once again\nunindented one more time\n",
716+
"unindented\n\tindented once\n#[\n|]#\tindented once and separated\n\tindented once again\nunindented one more time\n",
717+
1,
718+
),
719+
(
720+
"unindented\n\tindented once\n\t\tindented #[twice|]#\n\tindented once again\nunindented\n",
721+
"unindented\n#[\tindented once\n\t\tindented twice\n\tindented once again|]#\nunindented\n",
722+
2,
723+
),
724+
(
725+
"unindented\n\tindented once\n\t\tindented #[twice|]#\n\tindented once again\nunindented\n",
726+
"#[unindented\n\tindented once\n\t\tindented twice\n\tindented once again\nunindented\n|]#",
727+
3,
728+
),
729+
];
730+
731+
for (before, expected, count) in tests {
732+
let (s, selection) = crate::test::print(before);
733+
let text = Rope::from(s.as_str());
734+
let selection = selection.transform(|r| {
735+
textobject_indentation_level(text.slice(..), r, TextObject::Around, count, 4, 4)
736+
});
737+
let actual = crate::test::plain(s.as_ref(), &selection);
738+
assert_eq!(actual, expected, "\nbefore: `{:?}`", before);
739+
}
740+
}
741+
500742
#[test]
501743
fn test_textobject_surround() {
502744
// (text, [(cursor position, textobject, final range, surround char, count), ...])

helix-term/src/commands.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6097,6 +6097,14 @@ fn select_textobject(cx: &mut Context, objtype: textobject::TextObject) {
60976097
'e' => textobject_treesitter("entry", range),
60986098
'x' => textobject_treesitter("xml-element", range),
60996099
'p' => textobject::textobject_paragraph(text, range, objtype, count),
6100+
'i' => textobject::textobject_indentation_level(
6101+
text,
6102+
range,
6103+
objtype,
6104+
count,
6105+
doc.indent_width(),
6106+
doc.tab_width(),
6107+
),
61006108
'm' => textobject::textobject_pair_surround_closest(
61016109
doc.syntax(),
61026110
text,
@@ -6132,6 +6140,7 @@ fn select_textobject(cx: &mut Context, objtype: textobject::TextObject) {
61326140
("w", "Word"),
61336141
("W", "WORD"),
61346142
("p", "Paragraph"),
6143+
("i", "Indentation level"),
61356144
("t", "Type definition (tree-sitter)"),
61366145
("f", "Function (tree-sitter)"),
61376146
("a", "Argument/parameter (tree-sitter)"),

0 commit comments

Comments
 (0)