diff --git a/book/src/generated/static-cmd.md b/book/src/generated/static-cmd.md index 1b347290b9f1..967fd67a9c2c 100644 --- a/book/src/generated/static-cmd.md +++ b/book/src/generated/static-cmd.md @@ -313,3 +313,5 @@ | `goto_prev_tabstop` | Goto next snippet placeholder | | | `rotate_selections_first` | Make the first selection your primary one | | | `rotate_selections_last` | Make the last selection your primary one | | +| `fold` | Fold text objects | normal: `` Zf ``, `` zf ``, select: `` Zf ``, `` zf `` | +| `unfold` | Unfold text objects | normal: `` ZF ``, `` zF ``, select: `` ZF ``, `` zF `` | diff --git a/book/src/generated/typable-cmd.md b/book/src/generated/typable-cmd.md index e416e813f008..ca8cf6f0a82d 100644 --- a/book/src/generated/typable-cmd.md +++ b/book/src/generated/typable-cmd.md @@ -88,4 +88,6 @@ | `:yank-diagnostic` | Yank diagnostic(s) under primary cursor to register, or clipboard by default | | `:read`, `:r` | Load a file into buffer | | `:echo` | Prints the given arguments to the statusline. | +| `:fold` | Fold text. | +| `:unfold` | Unfold text. | | `:noop` | Does nothing. | diff --git a/helix-core/src/command_line.rs b/helix-core/src/command_line.rs index 8e209d6180e0..176cb95e4b4c 100644 --- a/helix-core/src/command_line.rs +++ b/helix-core/src/command_line.rs @@ -932,6 +932,10 @@ impl<'a> Args<'a> { self.positionals.join(sep) } + pub fn contains(&self, arg: &str) -> bool { + self.positionals.contains(&Cow::Borrowed(arg)) + } + /// Returns an iterator over all positional arguments. pub fn iter(&self) -> slice::Iter<'_, Cow<'_, str>> { self.positionals.iter() diff --git a/helix-core/src/doc_formatter.rs b/helix-core/src/doc_formatter.rs index d747094204c1..7ea8f34bbdf5 100644 --- a/helix-core/src/doc_formatter.rs +++ b/helix-core/src/doc_formatter.rs @@ -21,7 +21,7 @@ use unicode_segmentation::{Graphemes, UnicodeSegmentation}; use helix_stdx::rope::{RopeGraphemes, RopeSliceExt}; -use crate::graphemes::{Grapheme, GraphemeStr}; +use crate::graphemes::{next_grapheme_boundary, Grapheme, GraphemeStr}; use crate::syntax::Highlight; use crate::text_annotations::TextAnnotations; use crate::{Position, RopeSlice}; @@ -172,6 +172,8 @@ impl Default for TextFormat { #[derive(Debug)] pub struct DocumentFormatter<'t> { + text: RopeSlice<'t>, + text_fmt: &'t TextFormat, annotations: &'t TextAnnotations<'t>, @@ -210,14 +212,23 @@ impl<'t> DocumentFormatter<'t> { text: RopeSlice<'t>, text_fmt: &'t TextFormat, annotations: &'t TextAnnotations, - char_idx: usize, + mut char_idx: usize, ) -> Self { + // if `char_idx` is folded restore its value to the starting char of the block + if let Some(fold) = annotations + .folds + .superest_fold_containing(char_idx, |fold| fold.start.char..=fold.end.char) + { + char_idx = fold.start.char + } + // TODO divide long lines into blocks to avoid bad performance for long lines let block_line_idx = text.char_to_line(char_idx.min(text.len_chars())); let block_char_idx = text.line_to_char(block_line_idx); annotations.reset_pos(block_char_idx); DocumentFormatter { + text, text_fmt, annotations, visual_pos: Position { row: 0, col: 0 }, @@ -259,7 +270,15 @@ impl<'t> DocumentFormatter<'t> { } } - fn advance_grapheme(&mut self, col: usize, char_pos: usize) -> Option> { + fn advance_grapheme( + &mut self, + col: usize, + mut char_pos: usize, + ) -> Option> { + if let Some(folded_chars) = self.skip_folded_chars(char_pos) { + char_pos += folded_chars; + } + let (grapheme, source) = if let Some((grapheme, highlight)) = self.next_inline_annotation_grapheme(char_pos) { (grapheme.into(), GraphemeSource::VirtualText { highlight }) @@ -291,6 +310,31 @@ impl<'t> DocumentFormatter<'t> { Some(grapheme) } + fn skip_folded_chars(&mut self, char_pos: usize) -> Option { + let (folded_chars, folded_lines) = self + .annotations + .folds + .consume_next(char_pos, |fold| fold.start.char) + .map(|fold| { + ( + next_grapheme_boundary(self.text, fold.end.char) - fold.start.char, + fold.end.line - fold.start.line + 1, + ) + })?; + + if char_pos + folded_chars < self.text.len_chars() { + self.graphemes = self.text.slice(char_pos + folded_chars..).graphemes(); + } else { + self.graphemes = RopeSlice::from("").graphemes(); + } + self.annotations.reset_pos(char_pos + folded_chars); + + self.char_pos += folded_chars; + self.line_pos += folded_lines; + + Some(folded_chars) + } + /// Move a word to the next visual line fn wrap_word(&mut self) -> usize { // softwrap this word to the next line diff --git a/helix-core/src/graphemes.rs b/helix-core/src/graphemes.rs index 4cbb5746491a..72501e81d1ff 100644 --- a/helix-core/src/graphemes.rs +++ b/helix-core/src/graphemes.rs @@ -13,6 +13,7 @@ use std::ptr::NonNull; use std::{slice, str}; use crate::chars::{char_is_whitespace, char_is_word}; +use crate::text_folding::FoldAnnotations; use crate::LineEnding; #[inline] @@ -211,6 +212,84 @@ pub fn nth_next_grapheme_boundary(slice: RopeSlice, char_idx: usize, n: usize) - chunk_char_idx + tmp } +#[must_use] +// OPTIMIZE: consider inlining fold-checking into `nth_prev_grapheme_boundary` +// to avoid `prev_grapheme_boundary` loop calls. +pub fn nth_prev_folded_grapheme_boundary( + slice: RopeSlice, + annotations: &FoldAnnotations, + mut char_idx: usize, + n: usize, +) -> usize { + if n == 0 { + return char_idx; + } + + annotations.reset_pos(char_idx, |fold| fold.start.char); + + for _ in 0..n { + char_idx = prev_grapheme_boundary(slice, char_idx); + if let Some(fold) = annotations.consume_prev(char_idx, |fold| fold.end.char) { + char_idx = prev_grapheme_boundary(slice, fold.start.char) + } + } + + char_idx +} + +#[must_use] +// OPTIMIZE: consider inlining fold-checking into `nth_next_grapheme_boundary` +// to avoid `next_grapheme_boundary` loop calls. +pub fn nth_next_folded_grapheme_boundary( + slice: RopeSlice, + annotations: &FoldAnnotations, + mut char_idx: usize, + mut n: usize, +) -> usize { + if n == 0 { + return char_idx; + } + + annotations.reset_pos(char_idx, |fold| fold.start.char); + + // This function is used for `Range`, which utilizes a gap index. + // Consequently, the right index of `Range` may be positioned at the start fold point char. + // This code handles that specific case. + if let Some(fold) = annotations.consume_next(char_idx, |fold| fold.start.char) { + char_idx = next_grapheme_boundary(slice, fold.end.char); + n -= 1; + } + + for _ in 0..n { + char_idx = next_grapheme_boundary(slice, char_idx); + if let Some(fold) = annotations.consume_next(char_idx, |fold| fold.start.char) { + char_idx = next_grapheme_boundary(slice, fold.end.char) + } + } + + char_idx +} + +#[must_use] +#[inline(always)] +pub fn prev_folded_grapheme_boundary( + slice: RopeSlice, + annotations: &FoldAnnotations, + char_idx: usize, +) -> usize { + nth_prev_folded_grapheme_boundary(slice, annotations, char_idx, 1) +} + +#[must_use] +#[inline(always)] +pub fn next_folded_grapheme_boundary( + slice: RopeSlice, + annotations: &FoldAnnotations, + char_idx: usize, +) -> usize { + nth_next_folded_grapheme_boundary(slice, annotations, char_idx, 1) +} + /// Finds the next grapheme boundary after the given char position. #[must_use] #[inline(always)] diff --git a/helix-core/src/lib.rs b/helix-core/src/lib.rs index 09865ca40456..035f7cbb6eb5 100644 --- a/helix-core/src/lib.rs +++ b/helix-core/src/lib.rs @@ -29,6 +29,7 @@ pub mod surround; pub mod syntax; pub mod test; pub mod text_annotations; +pub mod text_folding; pub mod textobject; mod transaction; pub mod uri; diff --git a/helix-core/src/movement.rs b/helix-core/src/movement.rs index 09a99db2575f..bdc17baa14eb 100644 --- a/helix-core/src/movement.rs +++ b/helix-core/src/movement.rs @@ -1,19 +1,18 @@ use std::{borrow::Cow, cmp::Reverse, iter}; -use ropey::iter::Chars; - use crate::{ char_idx_at_visual_offset, chars::{categorize_char, char_is_line_ending, CharCategory}, doc_formatter::TextFormat, graphemes::{ - next_grapheme_boundary, nth_next_grapheme_boundary, nth_prev_grapheme_boundary, - prev_grapheme_boundary, + next_folded_grapheme_boundary, next_grapheme_boundary, nth_next_folded_grapheme_boundary, + nth_prev_folded_grapheme_boundary, prev_grapheme_boundary, }, - line_ending::rope_is_line_ending, + line_ending::{line_end_char_index, rope_is_line_ending}, position::char_idx_at_visual_block_offset, syntax, text_annotations::TextAnnotations, + text_folding::{ropex::FoldedChars, RopeSliceFoldExt}, textobject::TextObject, tree_sitter::Node, visual_offset_from_block, Range, RopeSlice, Selection, Syntax, @@ -38,14 +37,18 @@ pub fn move_horizontally( count: usize, behaviour: Movement, _: &TextFormat, - _: &mut TextAnnotations, + annotations: &mut TextAnnotations, ) -> Range { let pos = range.cursor(slice); // Compute the new position. let new_pos = match dir { - Direction::Forward => nth_next_grapheme_boundary(slice, pos, count), - Direction::Backward => nth_prev_grapheme_boundary(slice, pos, count), + Direction::Forward => { + nth_next_folded_grapheme_boundary(slice, &annotations.folds, pos, count) + } + Direction::Backward => { + nth_prev_folded_grapheme_boundary(slice, &annotations.folds, pos, count) + } }; // Compute the final new range. @@ -126,10 +129,32 @@ pub fn move_vertically( let line_idx = slice.char_to_line(pos); // Compute the new position. - let mut new_line_idx = match dir { - Direction::Forward => line_idx.saturating_add(count), - Direction::Backward => line_idx.saturating_sub(count), - }; + annotations + .folds + .reset_pos(line_idx, |fold| fold.start.line); + let mut new_line_idx = line_idx; + for _ in 0..count { + match dir { + Direction::Forward => { + new_line_idx += 1; + if let Some(fold) = annotations + .folds + .consume_next(new_line_idx, |fold| fold.start.line) + { + new_line_idx = fold.end.line + 1 + } + } + Direction::Backward => { + new_line_idx = new_line_idx.saturating_sub(1); + if let Some(fold) = annotations + .folds + .consume_prev(new_line_idx, |fold| fold.end.line) + { + new_line_idx = (fold).start.line - 1; + } + } + } + } let line = if new_line_idx >= slice.len_lines() - 1 { // there is no line terminator for the last line @@ -165,55 +190,193 @@ pub fn move_vertically( new_range } -pub fn move_next_word_start(slice: RopeSlice, range: Range, count: usize) -> Range { - word_move(slice, range, count, WordMotionTarget::NextWordStart) +pub fn move_next_word_start( + slice: RopeSlice, + annotations: &TextAnnotations, + range: Range, + count: usize, +) -> Range { + word_move( + slice, + annotations, + range, + count, + WordMotionTarget::NextWordStart, + ) } -pub fn move_next_word_end(slice: RopeSlice, range: Range, count: usize) -> Range { - word_move(slice, range, count, WordMotionTarget::NextWordEnd) +pub fn move_next_word_end( + slice: RopeSlice, + annotations: &TextAnnotations, + range: Range, + count: usize, +) -> Range { + word_move( + slice, + annotations, + range, + count, + WordMotionTarget::NextWordEnd, + ) } -pub fn move_prev_word_start(slice: RopeSlice, range: Range, count: usize) -> Range { - word_move(slice, range, count, WordMotionTarget::PrevWordStart) +pub fn move_prev_word_start( + slice: RopeSlice, + annotations: &TextAnnotations, + range: Range, + count: usize, +) -> Range { + word_move( + slice, + annotations, + range, + count, + WordMotionTarget::PrevWordStart, + ) } -pub fn move_prev_word_end(slice: RopeSlice, range: Range, count: usize) -> Range { - word_move(slice, range, count, WordMotionTarget::PrevWordEnd) +pub fn move_prev_word_end( + slice: RopeSlice, + annotations: &TextAnnotations, + range: Range, + count: usize, +) -> Range { + word_move( + slice, + annotations, + range, + count, + WordMotionTarget::PrevWordEnd, + ) } -pub fn move_next_long_word_start(slice: RopeSlice, range: Range, count: usize) -> Range { - word_move(slice, range, count, WordMotionTarget::NextLongWordStart) +pub fn move_next_long_word_start( + slice: RopeSlice, + annotations: &TextAnnotations, + range: Range, + count: usize, +) -> Range { + word_move( + slice, + annotations, + range, + count, + WordMotionTarget::NextLongWordStart, + ) } -pub fn move_next_long_word_end(slice: RopeSlice, range: Range, count: usize) -> Range { - word_move(slice, range, count, WordMotionTarget::NextLongWordEnd) +pub fn move_next_long_word_end( + slice: RopeSlice, + annotations: &TextAnnotations, + range: Range, + count: usize, +) -> Range { + word_move( + slice, + annotations, + range, + count, + WordMotionTarget::NextLongWordEnd, + ) } -pub fn move_prev_long_word_start(slice: RopeSlice, range: Range, count: usize) -> Range { - word_move(slice, range, count, WordMotionTarget::PrevLongWordStart) +pub fn move_prev_long_word_start( + slice: RopeSlice, + annotations: &TextAnnotations, + range: Range, + count: usize, +) -> Range { + word_move( + slice, + annotations, + range, + count, + WordMotionTarget::PrevLongWordStart, + ) } -pub fn move_prev_long_word_end(slice: RopeSlice, range: Range, count: usize) -> Range { - word_move(slice, range, count, WordMotionTarget::PrevLongWordEnd) +pub fn move_prev_long_word_end( + slice: RopeSlice, + annotations: &TextAnnotations, + range: Range, + count: usize, +) -> Range { + word_move( + slice, + annotations, + range, + count, + WordMotionTarget::PrevLongWordEnd, + ) } -pub fn move_next_sub_word_start(slice: RopeSlice, range: Range, count: usize) -> Range { - word_move(slice, range, count, WordMotionTarget::NextSubWordStart) +pub fn move_next_sub_word_start( + slice: RopeSlice, + annotations: &TextAnnotations, + range: Range, + count: usize, +) -> Range { + word_move( + slice, + annotations, + range, + count, + WordMotionTarget::NextSubWordStart, + ) } -pub fn move_next_sub_word_end(slice: RopeSlice, range: Range, count: usize) -> Range { - word_move(slice, range, count, WordMotionTarget::NextSubWordEnd) +pub fn move_next_sub_word_end( + slice: RopeSlice, + annotations: &TextAnnotations, + range: Range, + count: usize, +) -> Range { + word_move( + slice, + annotations, + range, + count, + WordMotionTarget::NextSubWordEnd, + ) } -pub fn move_prev_sub_word_start(slice: RopeSlice, range: Range, count: usize) -> Range { - word_move(slice, range, count, WordMotionTarget::PrevSubWordStart) +pub fn move_prev_sub_word_start( + slice: RopeSlice, + annotations: &TextAnnotations, + range: Range, + count: usize, +) -> Range { + word_move( + slice, + annotations, + range, + count, + WordMotionTarget::PrevSubWordStart, + ) } -pub fn move_prev_sub_word_end(slice: RopeSlice, range: Range, count: usize) -> Range { - word_move(slice, range, count, WordMotionTarget::PrevSubWordEnd) +pub fn move_prev_sub_word_end( + slice: RopeSlice, + annotations: &TextAnnotations, + range: Range, + count: usize, +) -> Range { + word_move( + slice, + annotations, + range, + count, + WordMotionTarget::PrevSubWordEnd, + ) } -fn word_move(slice: RopeSlice, range: Range, count: usize, target: WordMotionTarget) -> Range { +fn word_move( + slice: RopeSlice, + annotations: &TextAnnotations, + range: Range, + count: usize, + target: WordMotionTarget, +) -> Range { let is_prev = matches!( target, WordMotionTarget::PrevWordStart @@ -252,7 +415,10 @@ fn word_move(slice: RopeSlice, range: Range, count: usize, target: WordMotionTar // Do the main work. let mut range = start_range; for _ in 0..count { - let next_range = slice.chars_at(range.head).range_to_target(target, range); + let next_range = slice + // two distinct fold annotations are needed, therefore, folds are cloned + .folded_chars_at(&annotations.folds.clone(), range.head) + .range_to_target(slice, annotations, target, range); if range == next_range { break; } @@ -261,96 +427,135 @@ fn word_move(slice: RopeSlice, range: Range, count: usize, target: WordMotionTar range } -pub fn move_prev_paragraph( - slice: RopeSlice, +fn move_paragraph_impl( + text: RopeSlice, + direction: Direction, + annotations: &TextAnnotations, range: Range, count: usize, behavior: Movement, ) -> Range { - let mut line = range.cursor_line(slice); - let first_char = slice.line_to_char(line) == range.cursor(slice); - let prev_line_empty = rope_is_line_ending(slice.line(line.saturating_sub(1))); - let curr_line_empty = rope_is_line_ending(slice.line(line)); - let prev_empty_to_line = prev_line_empty && !curr_line_empty; - - // skip character before paragraph boundary - if prev_empty_to_line && !first_char { - line += 1; - } - let mut lines = slice.lines_at(line); - lines.reverse(); - let mut lines = lines.map(rope_is_line_ending).peekable(); - let mut last_line = line; - for _ in 0..count { - while lines.next_if(|&e| e).is_some() { - line -= 1; + use std::ops::ControlFlow; + + let fold_annotations = &annotations.folds; + + let abort = |cursor: usize| -> bool { + match direction { + Direction::Forward => { + next_folded_grapheme_boundary(text, fold_annotations, cursor) == text.len_chars() + } + Direction::Backward => cursor == 0, } - while lines.next_if(|&e| !e).is_some() { - line -= 1; + }; + + let line_is_empty = |line: usize| -> bool { rope_is_line_ending(text.line(line)) }; + + let target_is_reached = |cursor: usize| -> bool { + let cursor_line = text.char_to_line(cursor); + match direction { + Direction::Forward => { + let next_line = text.next_folded_line(fold_annotations, cursor_line); + line_is_empty(cursor_line) + && (!line_is_empty(next_line) || next_line == text.len_lines()) + } + Direction::Backward => { + let prev_line = text.prev_folded_line(fold_annotations, cursor_line); + let line_first_char = text.line_to_char(cursor_line); + !line_is_empty(cursor_line) && cursor == line_first_char && line_is_empty(prev_line) + } } - if line == last_line { - break; + }; + + let move_to_target = |mut cursor: usize| -> ControlFlow { + while !abort(cursor) { + let cursor_line = text.char_to_line(cursor); + + cursor = match direction { + Direction::Forward => { + let line_end_char = line_end_char_index(&text, cursor_line); + if cursor == line_end_char { + text.line_to_char(text.next_folded_line(fold_annotations, cursor_line)) + } else { + line_end_char + } + } + Direction::Backward => { + let line_first_char = text.line_to_char(cursor_line); + if cursor == line_first_char { + text.line_to_char(text.prev_folded_line(fold_annotations, cursor_line)) + } else { + line_first_char + } + } + }; + + if target_is_reached(cursor) { + return ControlFlow::Continue(cursor); + } } - last_line = line; - } + ControlFlow::Break(cursor) + }; - let head = slice.line_to_char(line); - let anchor = if behavior == Movement::Move { - // exclude first character after paragraph boundary - if prev_empty_to_line && first_char { - range.cursor(slice) - } else { - range.head + let cursor = range.cursor(text); + let mut new_cursor = cursor; + for _ in 0..count { + match move_to_target(new_cursor) { + ControlFlow::Continue(r) => new_cursor = r, + ControlFlow::Break(r) => { + new_cursor = r; + break; + } } - } else { - range.put_cursor(slice, head, true).anchor + } + + let head = match direction { + Direction::Forward => next_grapheme_boundary(text, new_cursor), + Direction::Backward => new_cursor, + }; + + let anchor = match behavior { + Movement::Extend => range.put_cursor(text, head, true).anchor, + Movement::Move => match (direction, target_is_reached(cursor)) { + (Direction::Forward, true) | (Direction::Backward, false) => range.head, + (Direction::Forward, false) | (Direction::Backward, true) => cursor, + }, }; + Range::new(anchor, head) } +pub fn move_prev_paragraph( + slice: RopeSlice, + annotations: &TextAnnotations, + range: Range, + count: usize, + behavior: Movement, +) -> Range { + move_paragraph_impl( + slice, + Direction::Backward, + annotations, + range, + count, + behavior, + ) +} + pub fn move_next_paragraph( slice: RopeSlice, + annotations: &TextAnnotations, range: Range, count: usize, behavior: Movement, ) -> Range { - let mut line = range.cursor_line(slice); - let last_char = - prev_grapheme_boundary(slice, slice.line_to_char(line + 1)) == range.cursor(slice); - let curr_line_empty = rope_is_line_ending(slice.line(line)); - let next_line_empty = - rope_is_line_ending(slice.line(slice.len_lines().saturating_sub(1).min(line + 1))); - let curr_empty_to_line = curr_line_empty && !next_line_empty; - - // skip character after paragraph boundary - if curr_empty_to_line && last_char { - line += 1; - } - let mut lines = slice.lines_at(line).map(rope_is_line_ending).peekable(); - let mut last_line = line; - for _ in 0..count { - while lines.next_if(|&e| !e).is_some() { - line += 1; - } - while lines.next_if(|&e| e).is_some() { - line += 1; - } - if line == last_line { - break; - } - last_line = line; - } - let head = slice.line_to_char(line); - let anchor = if behavior == Movement::Move { - if curr_empty_to_line && last_char { - range.head - } else { - range.cursor(slice) - } - } else { - range.put_cursor(slice, head, true).anchor - }; - Range::new(anchor, head) + move_paragraph_impl( + slice, + Direction::Forward, + annotations, + range, + count, + behavior, + ) } // ---- util ------------ @@ -410,14 +615,28 @@ pub enum WordMotionTarget { } pub trait CharHelpers { - fn range_to_target(&mut self, target: WordMotionTarget, origin: Range) -> Range; + fn range_to_target( + &mut self, + text: RopeSlice, + annotations: &TextAnnotations, + target: WordMotionTarget, + origin: Range, + ) -> Range; } -impl CharHelpers for Chars<'_> { +impl CharHelpers for FoldedChars<'_> { /// Note: this only changes the anchor of the range if the head is effectively /// starting on a boundary (either directly or after skipping newline characters). /// Any other changes to the anchor should be handled by the calling code. - fn range_to_target(&mut self, target: WordMotionTarget, origin: Range) -> Range { + fn range_to_target( + &mut self, + text: RopeSlice, + annotations: &TextAnnotations, + target: WordMotionTarget, + origin: Range, + ) -> Range { + let fold_annotations = &annotations.folds; + let is_prev = matches!( target, WordMotionTarget::PrevWordStart @@ -435,9 +654,27 @@ impl CharHelpers for Chars<'_> { // Function to advance index in the appropriate motion direction. let advance: &dyn Fn(&mut usize) = if is_prev { - &|idx| *idx = idx.saturating_sub(1) + &|idx| { + fold_annotations.reset_pos(*idx, |fold| fold.end.char); + if let Some(fold) = fold_annotations.consume_prev(*idx, |fold| fold.end.char) { + *idx = prev_grapheme_boundary(text, fold.start.char); + } + *idx = idx.saturating_sub(1); + if let Some(fold) = fold_annotations.consume_prev(*idx, |fold| fold.end.char) { + *idx = prev_grapheme_boundary(text, fold.start.char); + } + } } else { - &|idx| *idx += 1 + &|idx| { + fold_annotations.reset_pos(*idx, |fold| fold.start.char); + if let Some(fold) = fold_annotations.consume_next(*idx, |fold| fold.start.char) { + *idx = next_grapheme_boundary(text, fold.end.char); + } + *idx += 1; + if let Some(fold) = fold_annotations.consume_next(*idx, |fold| fold.start.char) { + *idx = next_grapheme_boundary(text, fold.end.char); + } + } }; // Initialize state variables. @@ -485,6 +722,14 @@ impl CharHelpers for Chars<'_> { self.reverse(); } + // ensure the anchor is not folded + if let Some(fold) = fold_annotations + .superest_fold_containing(anchor, |fold| fold.start.char..=fold.end.char) + { + if !is_prev { + anchor = next_grapheme_boundary(text, fold.end.char); + } + }; Range::new(anchor, head) } } @@ -563,6 +808,7 @@ fn reached_target(target: WordMotionTarget, prev_ch: char, next_ch: char) -> boo #[allow(clippy::too_many_arguments)] pub fn goto_treesitter_object( slice: RopeSlice, + annotations: &TextAnnotations, range: Range, object_name: &str, dir: Direction, @@ -576,15 +822,27 @@ pub fn goto_treesitter_object( let byte_pos = slice.char_to_byte(range.cursor(slice)); let cap_name = |t: TextObject| format!("{}.{}", object_name, t); - let nodes = textobject_query?.capture_nodes_any( - &[ - &cap_name(TextObject::Movement), - &cap_name(TextObject::Around), - &cap_name(TextObject::Inside), - ], - slice_tree, - slice, - )?; + let nodes = textobject_query? + .capture_nodes_any( + &[ + &cap_name(TextObject::Movement), + &cap_name(TextObject::Around), + &cap_name(TextObject::Inside), + ], + slice_tree, + slice, + )? + // filter out folds that are entirely folded + .filter(|cap_node| { + let start = slice.byte_to_char(cap_node.start_byte()); + let end = prev_grapheme_boundary(slice, slice.byte_to_char(cap_node.end_byte())); + [start, end].into_iter().any(|char| { + annotations + .folds + .superest_fold_containing(char, |fold| fold.start.char..=fold.end.char) + .is_none() + }) + }); let node = match dir { Direction::Forward => nodes @@ -974,19 +1232,34 @@ mod test { #[test] #[should_panic] fn nonsensical_ranges_panic_on_forward_movement_attempt_in_debug_mode() { - move_next_word_start(Rope::from("Sample").slice(..), Range::point(99999999), 1); + move_next_word_start( + Rope::from("Sample").slice(..), + &TextAnnotations::default(), + Range::point(99999999), + 1, + ); } #[test] #[should_panic] fn nonsensical_ranges_panic_on_forward_to_end_movement_attempt_in_debug_mode() { - move_next_word_end(Rope::from("Sample").slice(..), Range::point(99999999), 1); + move_next_word_end( + Rope::from("Sample").slice(..), + &TextAnnotations::default(), + Range::point(99999999), + 1, + ); } #[test] #[should_panic] fn nonsensical_ranges_panic_on_backwards_movement_attempt_in_debug_mode() { - move_prev_word_start(Rope::from("Sample").slice(..), Range::point(99999999), 1); + move_prev_word_start( + Rope::from("Sample").slice(..), + &TextAnnotations::default(), + Range::point(99999999), + 1, + ); } #[test] @@ -1069,7 +1342,12 @@ mod test { for (sample, scenario) in tests { for (count, begin, expected_end) in scenario.into_iter() { - let range = move_next_word_start(Rope::from(sample).slice(..), begin, count); + let range = move_next_word_start( + Rope::from(sample).slice(..), + &TextAnnotations::default(), + begin, + count, + ); assert_eq!(range, expected_end, "Case failed: [{}]", sample); } } @@ -1155,7 +1433,12 @@ mod test { for (sample, scenario) in tests { for (count, begin, expected_end) in scenario.into_iter() { - let range = move_next_sub_word_start(Rope::from(sample).slice(..), begin, count); + let range = move_next_sub_word_start( + Rope::from(sample).slice(..), + &TextAnnotations::default(), + begin, + count, + ); assert_eq!(range, expected_end, "Case failed: [{}]", sample); } } @@ -1241,7 +1524,12 @@ mod test { for (sample, scenario) in tests { for (count, begin, expected_end) in scenario.into_iter() { - let range = move_next_sub_word_end(Rope::from(sample).slice(..), begin, count); + let range = move_next_sub_word_end( + Rope::from(sample).slice(..), + &TextAnnotations::default(), + begin, + count, + ); assert_eq!(range, expected_end, "Case failed: [{}]", sample); } } @@ -1325,7 +1613,12 @@ mod test { for (sample, scenario) in tests { for (count, begin, expected_end) in scenario.into_iter() { - let range = move_next_long_word_start(Rope::from(sample).slice(..), begin, count); + let range = move_next_long_word_start( + Rope::from(sample).slice(..), + &TextAnnotations::default(), + begin, + count, + ); assert_eq!(range, expected_end, "Case failed: [{}]", sample); } } @@ -1410,7 +1703,12 @@ mod test { for (sample, scenario) in tests { for (count, begin, expected_end) in scenario.into_iter() { - let range = move_prev_word_start(Rope::from(sample).slice(..), begin, count); + let range = move_prev_word_start( + Rope::from(sample).slice(..), + &TextAnnotations::default(), + begin, + count, + ); assert_eq!(range, expected_end, "Case failed: [{}]", sample); } } @@ -1496,7 +1794,12 @@ mod test { for (sample, scenario) in tests { for (count, begin, expected_end) in scenario.into_iter() { - let range = move_prev_sub_word_start(Rope::from(sample).slice(..), begin, count); + let range = move_prev_sub_word_start( + Rope::from(sample).slice(..), + &TextAnnotations::default(), + begin, + count, + ); assert_eq!(range, expected_end, "Case failed: [{}]", sample); } } @@ -1593,7 +1896,12 @@ mod test { for (sample, scenario) in tests { for (count, begin, expected_end) in scenario.into_iter() { - let range = move_prev_long_word_start(Rope::from(sample).slice(..), begin, count); + let range = move_prev_long_word_start( + Rope::from(sample).slice(..), + &TextAnnotations::default(), + begin, + count, + ); assert_eq!(range, expected_end, "Case failed: [{}]", sample); } } @@ -1677,7 +1985,12 @@ mod test { for (sample, scenario) in tests { for (count, begin, expected_end) in scenario.into_iter() { - let range = move_next_word_end(Rope::from(sample).slice(..), begin, count); + let range = move_next_word_end( + Rope::from(sample).slice(..), + &TextAnnotations::default(), + begin, + count, + ); assert_eq!(range, expected_end, "Case failed: [{}]", sample); } } @@ -1759,7 +2072,12 @@ mod test { for (sample, scenario) in tests { for (count, begin, expected_end) in scenario.into_iter() { - let range = move_prev_word_end(Rope::from(sample).slice(..), begin, count); + let range = move_prev_word_end( + Rope::from(sample).slice(..), + &TextAnnotations::default(), + begin, + count, + ); assert_eq!(range, expected_end, "Case failed: [{}]", sample); } } @@ -1845,7 +2163,12 @@ mod test { for (sample, scenario) in tests { for (count, begin, expected_end) in scenario.into_iter() { - let range = move_prev_sub_word_end(Rope::from(sample).slice(..), begin, count); + let range = move_prev_sub_word_end( + Rope::from(sample).slice(..), + &TextAnnotations::default(), + begin, + count, + ); assert_eq!(range, expected_end, "Case failed: [{}]", sample); } } @@ -1927,7 +2250,12 @@ mod test { for (sample, scenario) in tests { for (count, begin, expected_end) in scenario.into_iter() { - let range = move_next_long_word_end(Rope::from(sample).slice(..), begin, count); + let range = move_next_long_word_end( + Rope::from(sample).slice(..), + &TextAnnotations::default(), + begin, + count, + ); assert_eq!(range, expected_end, "Case failed: [{}]", sample); } } @@ -2021,7 +2349,12 @@ mod test { for (sample, scenario) in tests { for (count, begin, expected_end) in scenario.into_iter() { - let range = move_prev_long_word_end(Rope::from(sample).slice(..), begin, count); + let range = move_prev_long_word_end( + Rope::from(sample).slice(..), + &TextAnnotations::default(), + begin, + count, + ); assert_eq!(range, expected_end, "Case failed: [{}]", sample); } } @@ -2054,8 +2387,15 @@ mod test { for (before, expected) in tests { let (s, selection) = crate::test::print(before); let text = Rope::from(s.as_str()); - let selection = - selection.transform(|r| move_prev_paragraph(text.slice(..), r, 1, Movement::Move)); + let selection = selection.transform(|r| { + move_prev_paragraph( + text.slice(..), + &TextAnnotations::default(), + r, + 1, + Movement::Move, + ) + }); let actual = crate::test::plain(s.as_ref(), &selection); assert_eq!(actual, expected, "\nbefore: `{:?}`", before); } @@ -2077,8 +2417,15 @@ mod test { for (before, expected) in tests { let (s, selection) = crate::test::print(before); let text = Rope::from(s.as_str()); - let selection = - selection.transform(|r| move_prev_paragraph(text.slice(..), r, 2, Movement::Move)); + let selection = selection.transform(|r| { + move_prev_paragraph( + text.slice(..), + &TextAnnotations::default(), + r, + 2, + Movement::Move, + ) + }); let actual = crate::test::plain(s.as_ref(), &selection); assert_eq!(actual, expected, "\nbefore: `{:?}`", before); } @@ -2100,8 +2447,15 @@ mod test { for (before, expected) in tests { let (s, selection) = crate::test::print(before); let text = Rope::from(s.as_str()); - let selection = selection - .transform(|r| move_prev_paragraph(text.slice(..), r, 1, Movement::Extend)); + let selection = selection.transform(|r| { + move_prev_paragraph( + text.slice(..), + &TextAnnotations::default(), + r, + 1, + Movement::Extend, + ) + }); let actual = crate::test::plain(s.as_ref(), &selection); assert_eq!(actual, expected, "\nbefore: `{:?}`", before); } @@ -2142,8 +2496,15 @@ mod test { for (before, expected) in tests { let (s, selection) = crate::test::print(before); let text = Rope::from(s.as_str()); - let selection = - selection.transform(|r| move_next_paragraph(text.slice(..), r, 1, Movement::Move)); + let selection = selection.transform(|r| { + move_next_paragraph( + text.slice(..), + &TextAnnotations::default(), + r, + 1, + Movement::Move, + ) + }); let actual = crate::test::plain(s.as_ref(), &selection); assert_eq!(actual, expected, "\nbefore: `{:?}`", before); } @@ -2165,8 +2526,15 @@ mod test { for (before, expected) in tests { let (s, selection) = crate::test::print(before); let text = Rope::from(s.as_str()); - let selection = - selection.transform(|r| move_next_paragraph(text.slice(..), r, 2, Movement::Move)); + let selection = selection.transform(|r| { + move_next_paragraph( + text.slice(..), + &TextAnnotations::default(), + r, + 2, + Movement::Move, + ) + }); let actual = crate::test::plain(s.as_ref(), &selection); assert_eq!(actual, expected, "\nbefore: `{:?}`", before); } @@ -2188,10 +2556,327 @@ mod test { for (before, expected) in tests { let (s, selection) = crate::test::print(before); let text = Rope::from(s.as_str()); - let selection = selection - .transform(|r| move_next_paragraph(text.slice(..), r, 1, Movement::Extend)); + let selection = selection.transform(|r| { + move_next_paragraph( + text.slice(..), + &TextAnnotations::default(), + r, + 1, + Movement::Extend, + ) + }); let actual = crate::test::plain(s.as_ref(), &selection); assert_eq!(actual, expected, "\nbefore: `{:?}`", before); } } + + mod with_folds { + use super::super::*; + + use helix_stdx::rope::RopeSliceExt; + + use crate::text_folding::FoldContainer; + use crate::{Position, Range}; + + use crate::text_folding::test_utils as utils; + use utils::{FOLDED_TEXT_SAMPLE, TEXT_SAMPLE}; + + fn unfold_position( + text: RopeSlice, + annotations: &TextAnnotations, + position: Position, + ) -> Position { + Position { + row: text.nth_next_folded_line(&annotations.folds, 0, position.row), + col: position.col, + } + } + + fn range_from_position(text: RopeSlice, position: Position) -> Range { + Range::point( + text.line_to_char(position.row) + + text + .line(position.row) + .graphemes() + .take(position.col) + .fold(0, |acc, g| acc + g.len_chars()), + ) + } + + fn position_from_range(text: RopeSlice, range: Range) -> Position { + let cursor = range.cursor(text); + let line = text.char_to_line(cursor); + Position { + row: line, + col: cursor - text.line_to_char(line), + } + } + + #[test] + fn test_move_horizontally() { + let container = &FoldContainer::from(*TEXT_SAMPLE, utils::fold_points()); + + let default_annotations = &mut TextAnnotations::default(); + let annotations = &mut TextAnnotations::default(); + annotations.add_folds(container); + + for (line, col, dir) in (0..FOLDED_TEXT_SAMPLE.len_lines()).flat_map(|line| { + let last_grapheme = FOLDED_TEXT_SAMPLE.line(line).graphemes().count() - 1; + [ + (line, last_grapheme, Direction::Forward), + (line, 0, Direction::Backward), + ] + }) { + let folded_position = Position::new(line, col); + let position = unfold_position(*TEXT_SAMPLE, annotations, folded_position); + + let folded_range = range_from_position(*FOLDED_TEXT_SAMPLE, folded_position); + let range = range_from_position(*TEXT_SAMPLE, position); + + let folded_expected = position_from_range( + *FOLDED_TEXT_SAMPLE, + move_horizontally( + *FOLDED_TEXT_SAMPLE, + folded_range, + dir, + 1, + Movement::Move, + &TextFormat::default(), + default_annotations, + ), + ); + let expected = unfold_position(*TEXT_SAMPLE, annotations, folded_expected); + let result = position_from_range( + *TEXT_SAMPLE, + move_horizontally( + *TEXT_SAMPLE, + range, + dir, + 1, + Movement::Move, + &TextFormat::default(), + annotations, + ), + ); + + assert_eq!( + result, expected, + "\n\ + \tfolded_position = {folded_position:?}\n\ + \tposition = {position:?}\n\ + \tdir = {dir:?}\n\ + \tfolded_expected = {folded_expected:?}\n", + ) + } + } + + #[test] + fn test_move_vertically() { + let container = FoldContainer::from(*TEXT_SAMPLE, utils::fold_points()); + + let default_annotations = &mut TextAnnotations::default(); + let annotations = &mut TextAnnotations::default(); + annotations.add_folds(&container); + + for (line, dir) in (0..FOLDED_TEXT_SAMPLE.len_lines()) + .flat_map(|line| [(line, Direction::Forward), (line, Direction::Backward)]) + { + let folded_position = Position::new(line, 0); + let position = unfold_position(*TEXT_SAMPLE, annotations, folded_position); + + let folded_range = range_from_position(*FOLDED_TEXT_SAMPLE, folded_position); + let range = range_from_position(*TEXT_SAMPLE, position); + + let folded_expected = position_from_range( + *FOLDED_TEXT_SAMPLE, + move_vertically( + *FOLDED_TEXT_SAMPLE, + folded_range, + dir, + 1, + Movement::Move, + &TextFormat::default(), + default_annotations, + ), + ); + let expected = unfold_position(*TEXT_SAMPLE, annotations, folded_expected); + let result = position_from_range( + *TEXT_SAMPLE, + move_vertically( + *TEXT_SAMPLE, + range, + dir, + 1, + Movement::Move, + &TextFormat::default(), + annotations, + ), + ); + + assert_eq!( + result, expected, + "\n\ + \tfolded_position = {folded_position:?}\n\ + \tposition = {position:?}\n\ + \tdir = {dir:?}\n\ + \tfolded_expected = {folded_expected:?}\n", + ) + } + } + + #[test] + fn test_move_vertically_visual() { + let container = FoldContainer::from(*TEXT_SAMPLE, utils::fold_points()); + + let default_annotations = &mut TextAnnotations::default(); + let annotations = &mut TextAnnotations::default(); + annotations.add_folds(&container); + + let text_format = &mut TextFormat::default(); + text_format.soft_wrap = true; + text_format.viewport_width = 7; + + for (line, dir) in (0..FOLDED_TEXT_SAMPLE.len_lines()) + .flat_map(|line| [(line, Direction::Forward), (line, Direction::Backward)]) + { + let folded_position = Position::new(line, 0); + let position = unfold_position(*TEXT_SAMPLE, annotations, folded_position); + + let folded_range = range_from_position(*FOLDED_TEXT_SAMPLE, folded_position); + let range = range_from_position(*TEXT_SAMPLE, position); + + let folded_expected = position_from_range( + *FOLDED_TEXT_SAMPLE, + move_vertically_visual( + *FOLDED_TEXT_SAMPLE, + folded_range, + dir, + 1, + Movement::Move, + text_format, + default_annotations, + ), + ); + let expected = unfold_position(*TEXT_SAMPLE, annotations, folded_expected); + let result = position_from_range( + *TEXT_SAMPLE, + move_vertically_visual( + *TEXT_SAMPLE, + range, + dir, + 1, + Movement::Move, + text_format, + annotations, + ), + ); + + assert_eq!( + result, expected, + "\n\ + \tfolded_position = {folded_position:?}\n\ + \tposition = {position:?}\n\ + \tdir = {dir:?}\n\ + \tfolded_expected = {folded_expected:?}\n", + ) + } + } + + #[test] + fn test_word_move() { + let container = &FoldContainer::from(*TEXT_SAMPLE, utils::fold_points()); + + let default_annotations = &mut TextAnnotations::default(); + let annotations = &mut TextAnnotations::default(); + annotations.add_folds(container); + + for (line, col, target) in (0..FOLDED_TEXT_SAMPLE.len_lines()).flat_map(|line| { + let last_grapheme = FOLDED_TEXT_SAMPLE.line(line).graphemes().count() - 1; + [ + (line, last_grapheme, WordMotionTarget::NextWordStart), + (line, 0, WordMotionTarget::PrevWordStart), + ] + }) { + let folded_position = Position::new(line, col); + let position = unfold_position(*TEXT_SAMPLE, annotations, folded_position); + + let folded_range = range_from_position(*FOLDED_TEXT_SAMPLE, folded_position); + let range = range_from_position(*TEXT_SAMPLE, position); + + let folded_expected = position_from_range( + *FOLDED_TEXT_SAMPLE, + word_move( + *FOLDED_TEXT_SAMPLE, + default_annotations, + folded_range, + 1, + target, + ), + ); + let expected = unfold_position(*TEXT_SAMPLE, annotations, folded_expected); + let result = position_from_range( + *TEXT_SAMPLE, + word_move(*TEXT_SAMPLE, annotations, range, 1, target), + ); + + assert_eq!( + result, expected, + "\n\ + \tfolded_position = {folded_position:?}\n\ + \tposition = {position:?}\n\ + \ttarget = {target:?}\n\ + \tfolded_expected = {folded_expected:?}\n", + ) + } + } + + #[test] + fn test_move_paragraph() { + let container = &FoldContainer::from(*TEXT_SAMPLE, utils::fold_points()); + + let default_annotations = &mut TextAnnotations::default(); + let annotations = &mut TextAnnotations::default(); + annotations.add_folds(container); + + for (line, col, dir) in (0..FOLDED_TEXT_SAMPLE.len_lines()).flat_map(|line| { + let last_grapheme = FOLDED_TEXT_SAMPLE.line(line).graphemes().count() - 1; + [ + (line, last_grapheme, Direction::Forward), + (line, 0, Direction::Backward), + ] + }) { + let folded_position = Position::new(line, col); + let position = unfold_position(*TEXT_SAMPLE, annotations, folded_position); + + let folded_range = range_from_position(*FOLDED_TEXT_SAMPLE, folded_position); + let range = range_from_position(*TEXT_SAMPLE, position); + + let folded_expected = position_from_range( + *FOLDED_TEXT_SAMPLE, + move_paragraph_impl( + *FOLDED_TEXT_SAMPLE, + dir, + default_annotations, + folded_range, + 1, + Movement::Move, + ), + ); + let expected = unfold_position(*TEXT_SAMPLE, annotations, folded_expected); + let result = position_from_range( + *TEXT_SAMPLE, + move_paragraph_impl(*TEXT_SAMPLE, dir, annotations, range, 1, Movement::Move), + ); + + assert_eq!( + result, expected, + "\n\ + \tfolded_position = {folded_position:?}\n\ + \tposition = {position:?}\n\ + \tdir = {dir:?}\n\ + \tfolded_expected = {folded_expected:?}\n", + ) + } + } + } } diff --git a/helix-core/src/search.rs b/helix-core/src/search.rs index 81cb412939df..345f5fa35a24 100644 --- a/helix-core/src/search.rs +++ b/helix-core/src/search.rs @@ -1,67 +1,132 @@ -use crate::RopeSlice; +use helix_stdx::rope::RopeSliceExt; -// TODO: switch to std::str::Pattern when it is stable. -pub trait CharMatcher { - fn char_match(&self, ch: char) -> bool; +use crate::{ + graphemes::{ + nth_next_folded_grapheme_boundary, nth_next_grapheme_boundary, + nth_prev_folded_grapheme_boundary, nth_prev_grapheme_boundary, + }, + text_folding::{ropex::RopeSliceFoldExt, FoldAnnotations}, + RopeSlice, +}; + +pub trait GraphemeMatcher { + fn grapheme_match(&self, g: RopeSlice) -> bool; } -impl CharMatcher for char { - fn char_match(&self, ch: char) -> bool { - *self == ch +impl GraphemeMatcher for char { + fn grapheme_match(&self, g: RopeSlice) -> bool { + g == RopeSlice::from(self.encode_utf8(&mut [0; 4]) as &str) } } -impl bool> CharMatcher for F { - fn char_match(&self, ch: char) -> bool { - (*self)(&ch) +impl bool> GraphemeMatcher for F { + fn grapheme_match(&self, g: RopeSlice) -> bool { + (*self)(g) } } -pub fn find_nth_next( +pub fn find_nth_next( text: RopeSlice, - char_matcher: M, - mut pos: usize, - n: usize, + matcher: impl GraphemeMatcher, + pos: usize, + mut n: usize, ) -> Option { - if pos >= text.len_chars() || n == 0 { + if n == 0 { return None; } - let mut chars = text.chars_at(pos); + let mut count = 0; + for (i, g) in text.graphemes_at(pos).skip(1).enumerate() { + if matcher.grapheme_match(g) { + count = i + 1; + n -= 1; + if n == 0 { + break; + } + } + } - for _ in 0..n { - loop { - let c = chars.next()?; + (n == 0).then(|| nth_next_grapheme_boundary(text, pos, count)) +} - pos += 1; +pub fn find_nth_prev( + text: RopeSlice, + matcher: impl GraphemeMatcher, + pos: usize, + mut n: usize, +) -> Option { + if n == 0 { + return None; + } - if char_matcher.char_match(c) { + let mut count = 0; + for (i, g) in text.graphemes_at(pos).reversed().enumerate() { + if matcher.grapheme_match(g) { + count = i + 1; + n -= 1; + if n == 0 { break; } } } - Some(pos - 1) + (n == 0).then(|| (nth_prev_grapheme_boundary(text, pos, count))) } -pub fn find_nth_prev(text: RopeSlice, ch: char, mut pos: usize, n: usize) -> Option { - if pos == 0 || n == 0 { +pub fn find_folded_nth_next( + text: RopeSlice, + annotations: &FoldAnnotations, + matcher: impl GraphemeMatcher, + pos: usize, + mut n: usize, +) -> Option { + if n == 0 { return None; } - let mut chars = text.chars_at(pos); + let mut count = 0; + for (i, g) in text + .folded_graphemes_at(annotations, text.char_to_byte(pos)) + .skip(1) + .enumerate() + { + if matcher.grapheme_match(g) { + count = i + 1; + n -= 1; + if n == 0 { + break; + } + } + } - for _ in 0..n { - loop { - let c = chars.prev()?; + (n == 0).then(|| nth_next_folded_grapheme_boundary(text, annotations, pos, count)) +} - pos -= 1; +pub fn find_folded_nth_prev( + text: RopeSlice, + annotations: &FoldAnnotations, + matcher: impl GraphemeMatcher, + pos: usize, + mut n: usize, +) -> Option { + if n == 0 { + return None; + } - if c == ch { + let mut count = 0; + for (i, g) in text + .folded_graphemes_at(annotations, text.char_to_byte(pos)) + .reversed() + .enumerate() + { + if matcher.grapheme_match(g) { + count = i + 1; + n -= 1; + if n == 0 { break; } } } - Some(pos) + (n == 0).then(|| nth_prev_folded_grapheme_boundary(text, annotations, pos, count)) } diff --git a/helix-core/src/syntax.rs b/helix-core/src/syntax.rs index 325c47ac65d8..8e78e863e3ee 100644 --- a/helix-core/src/syntax.rs +++ b/helix-core/src/syntax.rs @@ -23,7 +23,8 @@ use tree_house::{ query_iter::QueryIter, tree_sitter::{ query::{InvalidPredicateError, UserPredicate}, - Capture, Grammar, InactiveQueryCursor, InputEdit, Node, Pattern, Query, RopeInput, Tree, + Capture, Grammar, InactiveQueryCursor, InputEdit, Node, Pattern, Query, QueryCursor, + RopeInput, Tree, }, Error, InjectionLanguageMarker, LanguageConfig as SyntaxConfig, Layer, }; @@ -1031,24 +1032,71 @@ impl TextObjectQuery { node: &Node<'a>, slice: RopeSlice<'a>, ) -> Option>> { - let capture = capture_names + capture_names .iter() - .find_map(|cap| self.query.get_capture(cap))?; + .find_map(|cap| self.query.get_capture(cap)) + .map(|capture| { + let mut cursor = self.cursor(node, slice); + iter::from_fn(move || { + cursor + .next_match() + .map(|mat| mat.nodes_for_capture(capture).cloned().collect::>()) + }) + .filter_map(|nodes| match nodes.len() { + 0 => None, + 1 => nodes.into_iter().map(CapturedNode::Single).next(), + 2.. => Some(CapturedNode::Grouped(nodes)), + }) + }) + } - let mut cursor = InactiveQueryCursor::new(0..u32::MAX, TREE_SITTER_MATCH_LIMIT) - .execute_query(&self.query, node, RopeInput::new(slice)); - let capture_node = iter::from_fn(move || { - let (mat, _) = cursor.next_matched_node()?; - Some(mat.nodes_for_capture(capture).cloned().collect()) + pub fn capture_nodes_all<'a>( + &'a self, + capture_names: &'a [&str], + node: &Node<'a>, + slice: RopeSlice<'a>, + ) -> impl Iterator + use<'a> { + let mut cursor = self.cursor(node, slice); + let captures: Vec<_> = capture_names + .iter() + .filter_map(|name| self.query.get_capture(name)) + .collect(); + + iter::from_fn(move || { + cursor.next_match().map(|query_match| { + captures + .iter() + .filter_map(|&cap| { + let nodes: Vec<_> = query_match.nodes_for_capture(cap).cloned().collect(); + match nodes.len() { + 0 => None, + 1 => nodes + .into_iter() + .map(|n| (cap, CapturedNode::Single(n))) + .next(), + 2.. => Some((cap, CapturedNode::Grouped(nodes))), + } + }) + .collect::>() + }) }) - .filter_map(move |nodes: Vec<_>| { - if nodes.len() > 1 { - Some(CapturedNode::Grouped(nodes)) - } else { - nodes.into_iter().map(CapturedNode::Single).next() - } - }); - Some(capture_node) + .flatten() + } + + pub fn cursor<'a>( + &'a self, + node: &Node<'a>, + slice: RopeSlice<'a>, + ) -> QueryCursor<'_, '_, RopeInput> { + InactiveQueryCursor::new(0..u32::MAX, TREE_SITTER_MATCH_LIMIT).execute_query( + &self.query, + node, + RopeInput::new(slice), + ) + } + + pub fn query(&self) -> &Query { + &self.query } } @@ -1368,4 +1416,30 @@ mod test { source.len(), ); } + + #[test] + fn test_match_around_comment() { + let text = RopeSlice::from( + "fn f() {}\n\ + // abc\n\ + // def\n\ + // ghi\n\ + fn g() {}", + ); + + let lang = LOADER.language_for_name("rust").unwrap(); + let syntax = Syntax::new(text, lang, &LOADER).unwrap(); + let root_node = &syntax.tree().root_node(); + let textobject_query = LOADER.textobject_query(lang).unwrap(); + + let result = textobject_query + .capture_nodes("comment.around", root_node, text) + .unwrap() + .next() + .map(|node| node.byte_range()) + .unwrap(); + let expected = 10..30; + + assert_eq!(result, expected); + } } diff --git a/helix-core/src/text_annotations.rs b/helix-core/src/text_annotations.rs index 0f492b8be2e5..db5dab7adca5 100644 --- a/helix-core/src/text_annotations.rs +++ b/helix-core/src/text_annotations.rs @@ -6,6 +6,7 @@ use std::ptr::NonNull; use crate::doc_formatter::FormattedGrapheme; use crate::syntax::{Highlight, OverlayHighlights}; +use crate::text_folding::{FoldAnnotations, FoldContainer}; use crate::{Position, Tendril}; /// An inline annotation is continuous text shown @@ -279,6 +280,7 @@ pub struct TextAnnotations<'a> { inline_annotations: Vec>>, overlays: Vec>>, line_annotations: Vec<(Cell, RawBox)>, + pub folds: FoldAnnotations<'a>, } impl Debug for TextAnnotations<'_> { @@ -286,6 +288,7 @@ impl Debug for TextAnnotations<'_> { f.debug_struct("TextAnnotations") .field("inline_annotations", &self.inline_annotations) .field("overlays", &self.overlays) + .field("folds", &self.folds) .finish_non_exhaustive() } } @@ -295,11 +298,13 @@ impl<'a> TextAnnotations<'a> { pub fn reset_pos(&self, char_idx: usize) { reset_pos(&self.inline_annotations, char_idx, |annot| annot.char_idx); reset_pos(&self.overlays, char_idx, |annot| annot.char_idx); + self.folds.reset_pos(char_idx, |fold| fold.start.char); for (next_anchor, layer) in &self.line_annotations { next_anchor.set(unsafe { layer.get().reset_pos(char_idx) }); } } + // OPTIMIZE: skip folded highlights pub fn collect_overlay_highlights(&self, char_range: Range) -> OverlayHighlights { let mut highlights = Vec::new(); self.reset_pos(char_range.start); @@ -354,6 +359,13 @@ impl<'a> TextAnnotations<'a> { self } + pub fn add_folds(&mut self, fold_container: &'a FoldContainer) -> &mut Self { + if !fold_container.is_empty() { + self.folds.container = Some(fold_container); + } + self + } + /// Add new annotation lines. /// /// The line annotations **must be sorted** by their `char_idx`. diff --git a/helix-core/src/text_folding.rs b/helix-core/src/text_folding.rs new file mode 100644 index 000000000000..d423061a845a --- /dev/null +++ b/helix-core/src/text_folding.rs @@ -0,0 +1,879 @@ +//! This module provides text folding primitives. +//! +//! A fold consists of the following components: +//! +//! 1. **`Object`** - a type of fold, which indicates how a user has folded the text. +//! For example, the object is **Selection** when a user has folded arbitrarily selected text. +//! And the object is **TextObject** when a user has folded a text object, such as a function, class, and so on. +//! +//! 2. **`Header`** - a fragment that describes what is folded. +//! For example, the header of a folded function is its signature. +//! Additionally, headers are used to unfold text. +//! +//! 3. **`Target`** - a fragment that defines the block that will be folded. +//! For example, for a function, the target is a span of the **function.inside** capture. +//! +//! 4. **`Block`** - a folded (non-visible) text. It is a range of lines. +//! +//! Look at the following code: +//! ``` +//! fn f(a: u32) -> u32 { +//! a + a +//! } +//! ``` +//! Let's assume that a user has folded the `f` function, +//! thus the new fold has been created with the following components: +//! +//! - **`Object`** is **TextObject** with the value **function**. +//! +//! - **`Header`** is the fragment **"fn f(a: u32) -> u32"**. +//! +//! - **`Target`** is the fragment that spans the **function.inside** capture. +//! +//! - **`Block`** is the range of lines from 2 through 3. +//! +//! The block spans only two lines. +//! This is because the start line of the target has supplementary non-whitespace text. +//! The block must contain only the text described by the header. +//! In this case, that text is the function definition (textobject function.inside). +//! +//! Consider the additional example: +//! ``` +//! fn f(a: T) -> T +//! where +//! T: std::ops::Add + Copy +//! /* interfering comment*/ { +//! a + a +//! } /* interfering comment */ +//! +//! fn g(b: U) -> U +//! where +//! U: std::ops::Sub + Copy +//! { +//! b - b +//! } +//! ``` +//! The `f` and `g` functions are also folded. Let the folds be called `F` and `G`, respectively. +//! These folds differ in their blocks. Their blocks span one line and three lines, respectively. +//! This is because the `F` fold target has supplementary comments at its boundary lines, but the `G` fold target does not. +//! However, if a user removes the interfering comments the `F` fold block will be extended to three lines. +//! +//! The process of calculating a block is called **normalization**. +//! +//! Folds can be nested within others. +//! ``` +//! trait T { +//! fn f() { +//! println!("hello world"); +//! } +//! } +//! ``` +//! If trait `T` and function `f` are folded. +//! Then, the fold of function `f` is nested in the fold of trait `T`. +//! Folds that span other folds are called **super** folds. +//! Folds that are not nested are called **superest** folds. + +use std::cell::Cell; +use std::cmp::{max, min, Ordering}; +use std::fmt; +use std::iter::once; +use std::ops; + +use helix_stdx::rope::RopeSliceExt; + +use crate::graphemes::prev_grapheme_boundary; +use crate::line_ending::line_end_byte_index; +use crate::line_ending::line_end_char_index; +use crate::line_ending::rope_is_line_ending; +use crate::Range; +use crate::RopeSlice; +use crate::Selection; + +pub use ropex::RopeSliceFoldExt; + +pub mod ropex; +mod transaction; + +#[cfg(test)] +pub(crate) mod test_utils; + +#[cfg(test)] +mod test; + +/// A kind of fold. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub enum FoldObject { + /// Indicates an arbitrary folded text + Selection, + /// Indicates a folded text of text object (class, function, etc.) + TextObject(&'static str), +} + +impl fmt::Display for FoldObject { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Selection => write!(f, "something"), + Self::TextObject(textobject) => write!(f, "{textobject}"), + } + } +} + +/// A start of fold. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StartFoldPoint { + pub object: FoldObject, + + /// The first char of header. + pub header: usize, + + /// The first char of target. + pub target: usize, + + /// The first byte of block. + pub byte: usize, + /// The first char of block. + pub char: usize, + /// The first line of block. + pub line: usize, + + /// An index of `EndFoldPoint` relating to the same fold. + link: usize, + /// An index of `StartFoldPoint` relating to the super fold. + super_link: Option, +} + +impl StartFoldPoint { + /// Returns the fold. + pub fn fold<'a>(&'a self, container: &'a FoldContainer) -> Fold { + Fold::new(self, &container.end_points[self.link]) + } + + pub fn is_superest(&self) -> bool { + self.super_link.is_none() + } + + fn from(text: RopeSlice, object: FoldObject, header: usize, target: usize) -> Self { + let mut result = Self { + object, + header, + target, + byte: 0, + char: 0, + line: 0, + link: 0, + super_link: None, + }; + result.set_block(text, result.block_line(text)); + result + } + + /// Returns the first line of the block. + fn block_line(&self, text: RopeSlice) -> usize { + let truncate = text + .graphemes_at(text.char_to_byte(self.target)) + .reversed() + .take_while(|&g| !rope_is_line_ending(g)) + .flat_map(|g| g.chars()) + .any(|c| !c.is_whitespace()); + + text.char_to_line(self.target) + truncate as usize + } + + /// Sets `byte`, `char`, `line` fields. + fn set_block(&mut self, text: RopeSlice, line: usize) { + self.byte = text.line_to_byte(line); + self.char = text.line_to_char(line); + self.line = line; + } +} + +/// An end of fold. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EndFoldPoint { + /// The last char of target. + pub target: usize, + + /// The last grapheme aligned byte of block + pub byte: usize, + /// The last grapheme aligned char of blcok. + pub char: usize, + /// The last line of block. + pub line: usize, + + /// An index of `StartFoldPoint` of the same fold. + link: usize, +} + +impl EndFoldPoint { + /// Returns the fold. + pub fn fold<'a>(&'a self, container: &'a FoldContainer) -> Fold { + Fold::new(&container.start_points[self.link], self) + } + + fn from(text: RopeSlice, target: usize) -> Self { + let mut result = Self { + target, + byte: 0, + char: 0, + line: 0, + link: 0, + }; + result.set_block(text, result.block_line(text)); + result + } + + /// Returns the last line of the block. + fn block_line(&self, text: RopeSlice) -> usize { + let truncate = text + .graphemes_at(text.char_to_byte(self.target)) + .skip({ + let end_char = line_end_char_index(&text, text.char_to_line(self.target)); + (self.target != end_char) as usize + }) + .take_while(|&g| !rope_is_line_ending(g)) + .flat_map(|g| g.chars()) + .any(|c| !c.is_whitespace()); + + text.char_to_line(self.target) - truncate as usize + } + + /// Sets `byte`, `char`, `line` fields. + fn set_block(&mut self, text: RopeSlice, line: usize) { + self.byte = line_end_byte_index(&text, line); + self.char = line_end_char_index(&text, line); + self.line = line; + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Fold<'a> { + pub start: &'a StartFoldPoint, + pub end: &'a EndFoldPoint, +} + +impl<'a> Fold<'a> { + /// Creates a pair of fold points. + pub fn new_points( + text: RopeSlice, + object: FoldObject, + header: usize, + target: &ops::RangeInclusive, + ) -> (StartFoldPoint, EndFoldPoint) { + ( + StartFoldPoint::from(text, object, header, *target.start()), + EndFoldPoint::from(text, *target.end()), + ) + } + + pub fn new(start: &'a StartFoldPoint, end: &'a EndFoldPoint) -> Self { + Self { start, end } + } + + pub fn object(self) -> &'a FoldObject { + &self.start.object + } + + pub fn header(self) -> usize { + self.start.header + } + + pub fn is_superest(self) -> bool { + self.start.super_link.is_none() + } + + pub fn super_fold(self, container: &'a FoldContainer) -> Option { + self.start + .super_link + .map(|idx| container.start_points[idx].fold(container)) + } + + pub fn superest_fold(self, container: &'a FoldContainer) -> Option { + self.super_fold(container) + .map(|super_fold| super_fold.superest_fold(container).unwrap_or(super_fold)) + } + + /// Returns the index of the start fold point. + pub fn start_idx(self) -> usize { + self.end.link + } + + /// Returns the index of the end fold point. + pub fn end_idx(self) -> usize { + self.start.link + } +} + +/// A fold manager. +/// All folds of `View` are contained in it. +#[derive(Debug, Default, Clone)] +pub struct FoldContainer { + start_points: Vec, + end_points: Vec, +} + +impl FoldContainer { + pub fn new() -> Self { + Self::default() + } + + pub fn is_empty(&self) -> bool { + self.start_points.is_empty() + } + + pub fn len(&self) -> usize { + self.start_points.len() + } + + pub fn reserve(&mut self, additional: usize) { + self.start_points.reserve(additional); + self.end_points.reserve(additional); + } + + pub fn clear(&mut self) { + self.start_points.clear(); + self.end_points.clear(); + } + + pub fn from(text: RopeSlice, points: Vec<(StartFoldPoint, EndFoldPoint)>) -> Self { + let mut ret = Self::new(); + ret.add(text, points); + ret + } + + /// Adds new folds to the container. + pub fn add(&mut self, text: RopeSlice, points: Vec<(StartFoldPoint, EndFoldPoint)>) { + self.reserve(points.len()); + + for (mut sfp, mut efp) in points.into_iter() { + sfp.link = self.len(); + efp.link = self.len(); + self.start_points.push(sfp); + self.end_points.push(efp); + } + + self.sort_start_points(); + + let deletables = self.normalize(text); + self.delete(deletables); + + self.sort_end_points(); + self.set_super_links(); + } + + /// Adds new folds to the container and removes existing ones that overlap them. + pub fn replace(&mut self, text: RopeSlice, mut points: Vec<(StartFoldPoint, EndFoldPoint)>) { + // `true` if `f1` overlaps with `f2` + let overlap = |f1: Fold, f2: Fold| { + let range = |fold: Fold| { + let start = text.char_to_line(fold.start.header); + let end = text.char_to_line(fold.end.target); + start..=end + }; + + let r1 = range(f1); + let r2 = range(f2); + + let start = max(*r1.start(), *r2.start()); + let end = min(*r1.end(), *r2.end()); + + !(start..=end).is_empty() + }; + + for (sfp, efp) in points.iter_mut() { + let replacement = Fold::new(sfp, efp); + + // collect folds that overlap with replacement + let overlappables: Vec<_> = self + .start_points + .iter() + .map(|sfp| sfp.fold(self)) + .filter_map(|fold| overlap(fold, replacement).then_some(fold.start_idx())) + .collect(); + + // extend replacement + if let Some(fold) = overlappables + .last() + .map(|&i| self.start_points[i].fold(self)) + { + efp.target = max(efp.target, fold.end.target); + } + + self.remove(text, overlappables); + } + + self.add(text, points); + } + + /// Removes folds from the container for the passed `start_indices`. + /// # Invariant + /// Start indices must be sorted and unique. + pub fn remove(&mut self, text: RopeSlice, start_indices: Vec) { + self.delete(start_indices); + + let removables = self.normalize(text); + self.delete(removables); + + self.sort_end_points(); + self.set_super_links(); + } + + /// Removes folds that contain the anchor or the head of a selection. + pub fn remove_by_selection(&mut self, text: RopeSlice, selection: &Selection) { + let mut removables: Vec<_> = selection + .iter() + .flat_map(|range| { + let (start, end) = range.line_range(text); + once(start).chain((start != end).then_some(end)) + }) + .filter_map(|line| { + // the range of folds that potentially contain `line` + let range = { + let start = { + let start_fold = self + .end_points + .get(self.end_points.partition_point(|efp| efp.line < line)) + .map(|efp| efp.fold(self))?; + start_fold + .superest_fold(self) + .unwrap_or(start_fold) + .start_idx() + }; + let end = + start + self.start_points[start..].partition_point(|sfp| sfp.line <= line); + + start..end + }; + + let self_ref = &*self; + Some(self_ref.start_points[range].iter().filter_map(move |sfp| { + let fold = sfp.fold(self_ref); + let block = fold.start.line..=fold.end.line; + block.contains(&line).then_some(fold.start_idx()) + })) + }) + .flatten() + .collect(); + + removables.sort(); + removables.dedup(); + + self.remove(text, removables); + } + + /// Moves the left side of `range` to the start of the header if it is contained in the fold. + /// Moves the right side of `range` to the end of the header if it is contained in the fold. + pub fn throw_range_out_of_folds(&self, text: RopeSlice, range: Range) -> Range { + let block = |fold: Fold| fold.start.char..=fold.end.char; + + let from = self + .superest_fold_containing(range.from(), block) + .map(|fold| fold.start.header); + + let to = self + .superest_fold_containing( + if range.is_empty() { + range.to() + } else { + prev_grapheme_boundary(text, range.to()) + }, + block, + ) + .map(|fold| match fold.start.char.cmp(&fold.start.target) { + Ordering::Greater => fold.start.target, + _ => fold.start.char, + }); + + Range::new(from.unwrap_or(range.from()), to.unwrap_or(range.to())) + .with_direction(range.direction()) + } + + /// Finds fold. + pub fn find( + &self, + object: &FoldObject, + range: &ops::RangeInclusive, + mut get_range: impl FnMut(Fold) -> ops::RangeInclusive, + ) -> Option { + self.start_points + .binary_search_by(|sfp| { + let fold_range = get_range(sfp.fold(self)); + fold_range + .start() + .cmp(range.start()) + .then(fold_range.end().cmp(range.end()).reverse()) + .then(sfp.object.cmp(object)) + }) + .map(|idx| self.start_points[idx].fold(self)) + .ok() + } + + pub fn start_points_in_range( + &self, + range: &ops::RangeInclusive, + mut get_idx: impl FnMut(&StartFoldPoint) -> usize, + ) -> &[StartFoldPoint] { + let start = self + .start_points + .partition_point(|sfp| get_idx(sfp) < *range.start()); + + let Some(start_points) = self.start_points.get(start..) else { + return &[]; + }; + + let end = start + start_points.partition_point(|sfp| get_idx(sfp) <= *range.end()); + + &self.start_points[start..end] + } + + pub fn fold_containing( + &self, + idx: usize, + mut get_range: impl FnMut(Fold) -> ops::RangeInclusive, + ) -> Option { + let end_idx = self.end_points.partition_point(|efp| { + let range = get_range(efp.fold(self)); + *range.end() < idx + }); + + let mut fold = self.end_points.get(end_idx)?.fold(self); + while !get_range(fold).contains(&idx) { + fold = match fold.super_fold(self) { + Some(fold) => fold, + None => return None, + } + } + + Some(fold) + } + + pub fn superest_fold_containing( + &self, + idx: usize, + get_range: impl FnMut(Fold) -> ops::RangeInclusive, + ) -> Option { + self.fold_containing(idx, get_range) + .map(|fold| fold.superest_fold(self).unwrap_or(fold)) + } + + pub fn start_points(&self) -> &[StartFoldPoint] { + &self.start_points + } +} + +impl FoldContainer { + fn sort_start_points(&mut self) { + self.start_points.sort_by(|sfp1, sfp2| { + let efp1 = &self.end_points[sfp1.link]; + let efp2 = &self.end_points[sfp2.link]; + sfp1.target + .cmp(&sfp2.target) + .then(efp1.target.cmp(&efp2.target).reverse()) + .then(sfp1.object.cmp(&sfp2.object)) + .then_with(|| unreachable!("Unexpected doubles.")) + }); + + for (i, sfp) in self.start_points.iter().enumerate() { + self.end_points[sfp.link].link = i; + } + } + + fn sort_end_points(&mut self) { + self.end_points.sort_by(|efp1, efp2| { + efp1.target + .cmp(&efp2.target) + .then(efp1.link.cmp(&efp2.link).reverse()) + .then_with(|| unreachable!("Unexpected doubles.")) + }); + + for (i, efp) in self.end_points.iter().enumerate() { + self.start_points[efp.link].link = i; + } + } + + /// Normalizes folds and returns start indices of folds to remove. + /// + /// # Invariant + /// Returned folds must be removed. + fn normalize(&mut self, text: RopeSlice) -> Vec { + let range = |fold: Fold| { + let start = fold.header(); + let end = fold.end.target; + start..=end + }; + + // Returns `true` if `r1` and `r2` overlap + let overlap = |r1: &ops::RangeInclusive<_>, r2: &ops::RangeInclusive<_>| { + let start = max(*r1.start(), *r2.start()); + let end = min(*r1.end(), *r2.end()); + + !(start..=end).is_empty() + }; + + // Returns `true` if `r1` spans `r2` + let span = |r1: &ops::RangeInclusive<_>, r2: &ops::RangeInclusive<_>| { + let start = max(*r1.start(), *r2.start()); + let end = min(*r1.end(), *r2.end()); + + (start..=end) == *r2 + }; + + let mut removables = Vec::new(); + for i in 0..self.len() { + let fold = self.start_points[i].fold(self); + + // get the start line of the block + let block_start = { + let init = fold.start.block_line(text); + self.start_points + .iter() + .take(i) + .rev() + .map(|sfp| sfp.fold(self)) + .take_while(|&prev_fold| prev_fold.end.line == init - 1) + .find_map(|prev_fold| { + (!removables.contains(&prev_fold.start_idx())).then_some(init + 1) + }) + .unwrap_or(init) + }; + + // if the subsequent fold that overlaps with the current fold is found, + // then add the current fold to removables + if self + .start_points + .iter() + .skip(i + 1) + .map(|next_sfp| next_sfp.fold(self)) + .take_while(|&next_fold| overlap(&range(fold), &range(next_fold))) + .find_map(|next_fold| { + let fold_range = &range(fold); + let next_fold_range = &range(fold); + + (!span(fold_range, next_fold_range) && !span(next_fold_range, fold_range)) + .then(|| text.char_to_line(next_fold.header()) - 1) + }) + .is_some() + { + removables.push(i); + continue; + } + + let block_end = fold.end.block_line(text); + + if block_start > block_end { + removables.push(i); + continue; + } + + let sfp = &mut self.start_points[i]; + let efp = &mut self.end_points[sfp.link]; + + sfp.set_block(text, block_start); + efp.set_block(text, block_end); + } + + removables + } + + // Sets hierarchy of folds. + fn set_super_links(&mut self) { + if self.is_empty() { + return; + } + let full_range = 0..=self.len() - 1; + self.set_super_links_impl(&full_range, None, 0); + } + + fn set_super_links_impl( + &mut self, + range: &ops::RangeInclusive, + super_link: Option, + nesting: usize, + ) { + let mut idx = *range.start(); + while idx <= *range.end() { + self.start_points[idx].super_link = super_link; + if idx == *range.end() { + return; + } + + let nested_range = { + let fold = self.start_points[idx].fold(self); + let start = fold.start_idx() + 1; + let end = min(*range.end(), fold.end_idx() + nesting); + start..=end + }; + + if nested_range.is_empty() { + idx += 1; + } else { + self.set_super_links_impl(&nested_range, Some(idx), nesting + 1); + idx = *nested_range.end() + 1; + } + } + } + + /// Just deletes folds at `start_indices` + /// # Attention + /// It is service method. + /// It is probably not the method you want to use; see `remove` method. + fn delete(&mut self, start_indices: Vec) { + for start_idx in start_indices.into_iter().rev() { + let end_idx = self.start_points[start_idx].link; + + // remove start point + self.start_points.remove(start_idx); + for sfp in self.start_points.iter().skip(start_idx) { + self.end_points[sfp.link].link -= 1; + } + + // remove end point + self.end_points.remove(end_idx); + for efp in self.end_points.iter().skip(end_idx) { + self.start_points[efp.link].link -= 1; + } + } + } +} + +#[derive(Debug, Default, Clone)] +pub struct FoldAnnotations<'a> { + pub(super) container: Option<&'a FoldContainer>, + pub(super) current_index: Cell, +} + +impl<'a> FoldAnnotations<'a> { + pub fn new(container: Option<&'a FoldContainer>) -> Self { + Self { + container, + current_index: Cell::new(0), + } + } + + /// `None` when container is empty. + pub fn container(&self) -> Option<&'a FoldContainer> { + self.container.filter(|container| !container.is_empty()) + } + + pub fn reset_pos(&self, idx: usize, mut get_idx: impl FnMut(Fold) -> usize) { + let Some(container) = self.container() else { + return; + }; + + let new_index = container + .start_points + .partition_point(|sfp| get_idx(sfp.fold(container)) < idx); + + // verify that the fold at the new index is superest + if let Some(fold) = container + .start_points + .get(new_index) + .map(|sfp| sfp.fold(container)) + .filter(|fold| !fold.is_superest()) + { + let msg = format!( + "Unexpected nested fold.\n\ + idx = {idx}\n\ + fold = {fold:#?}" + ); + if cfg!(debug_assertions) { + panic!("{msg}"); + } else { + log::error!("{msg}"); + } + } + + self.current_index.set(new_index as isize); + } + + /// Returns the next fold if `idx` is equal to `get_idx(fold)`. + pub fn consume_next( + &self, + idx: usize, + mut get_idx: impl FnMut(Fold) -> usize, + ) -> Option> { + let container = self.container()?; + let current_index: usize = self.current_index.get().try_into().ok()?; + let fold = container + .start_points + .get(current_index) + .map(|sfp| sfp.fold(container))?; + + let fold_idx = get_idx(fold); + + if fold_idx < idx { + let msg = format!( + "Unexpected fold.\n\ + idx = {idx}\n\ + fold index = {fold_idx}\n\ + fold = {fold:#?}" + ); + if cfg!(debug_assertions) { + panic!("{msg}"); + } else { + log::error!("{msg}"); + } + } + + (fold_idx == idx).then(|| { + self.current_index.set(fold.end_idx() as isize + 1); + fold + }) + } + + /// Returns the previous fold if `idx` is equal to `get_idx(fold)`. + pub fn consume_prev(&self, idx: usize, mut get_idx: impl FnMut(Fold) -> usize) -> Option { + let container = self.container()?; + let current_index: usize = (self.current_index.get() - 1).try_into().ok()?; + let fold = container + .end_points + .get(current_index) + .map(|efp| efp.fold(container))?; + + let fold_idx = get_idx(fold); + + if fold_idx > idx { + let msg = format!( + "Unexpected fold.\n\ + idx = {idx}\n\ + fold idx = {fold_idx}\n\ + fold = {fold:#?}" + ); + if cfg!(debug_assertions) { + panic!("{msg}"); + } else { + log::error!("{msg}"); + } + } + + (fold_idx == idx).then(|| { + self.current_index.set(fold.start_idx() as isize); + fold + }) + } + + pub fn superest_fold_containing( + &self, + idx: usize, + get_range: impl FnMut(Fold) -> ops::RangeInclusive, + ) -> Option { + self.container() + .and_then(|container| container.superest_fold_containing(idx, get_range)) + } + + /// Returns the number of folded lines that are included in `line_range`. + /// # Invariant + /// `line_range` must have non-folded start and end lines. + pub fn folded_lines_between(&self, line_range: &ops::RangeInclusive) -> usize { + let Some(container) = self.container() else { + return 0; + }; + + container + .start_points_in_range(line_range, |sfp| sfp.line) + .iter() + .map(|sfp| sfp.fold(container)) + .filter(|fold| fold.is_superest()) + .map(|fold| fold.end.line - fold.start.line + 1) + .sum() + } +} diff --git a/helix-core/src/text_folding/ropex.rs b/helix-core/src/text_folding/ropex.rs new file mode 100644 index 000000000000..f58093f6a512 --- /dev/null +++ b/helix-core/src/text_folding/ropex.rs @@ -0,0 +1,526 @@ +//! Implements fold-oriented methods for `RopeSlice`. + +use crate::ropey::iter::{Chars, Lines}; +use crate::RopeSlice; + +use helix_stdx::rope::{RopeGraphemes, RopeSliceExt}; + +use super::FoldAnnotations; + +pub trait RopeSliceFoldExt<'a> { + /// Similar to the native `chars` method. + fn folded_chars(&self, annotations: &'a FoldAnnotations<'a>) -> FoldedChars<'a>; + + /// Similar to the extended `graphemes` method in the `RopeSliceExt` trait. + fn folded_graphemes(&self, annotations: &'a FoldAnnotations<'a>) -> FoldedGraphemes<'a>; + + /// Similar to the native `lines` method. + fn folded_lines(&self, annotations: &'a FoldAnnotations<'a>) -> FoldedLines<'a>; + + /// Similar to the native `chars_at` method. + fn folded_chars_at( + &self, + annotations: &'a FoldAnnotations<'a>, + char_idx: usize, + ) -> FoldedChars<'a>; + + /// Similar to the extended `graphemes_at` method in the `RopeSliceExt` trait. + fn folded_graphemes_at( + &self, + annotations: &'a FoldAnnotations<'a>, + byte_idx: usize, + ) -> FoldedGraphemes<'a>; + + /// Similar to the native `lines_at` method. + fn folded_lines_at( + &self, + annotations: &'a FoldAnnotations<'a>, + line_idx: usize, + ) -> FoldedLines<'a>; + + fn next_folded_char(&self, annotations: &'a FoldAnnotations<'a>, char_idx: usize) -> usize; + fn next_folded_grapheme(&self, annotations: &'a FoldAnnotations<'a>, byte_idx: usize) -> usize; + fn next_folded_line(&self, annotations: &'a FoldAnnotations<'a>, line_idx: usize) -> usize; + fn prev_folded_char(&self, annotations: &'a FoldAnnotations<'a>, char_idx: usize) -> usize; + fn prev_folded_grapheme(&self, annotations: &'a FoldAnnotations<'a>, byte_idx: usize) -> usize; + fn prev_folded_line(&self, annotations: &'a FoldAnnotations<'a>, line_idx: usize) -> usize; + fn nth_next_folded_char( + &self, + annotations: &'a FoldAnnotations<'a>, + char_idx: usize, + count: usize, + ) -> usize; + fn nth_next_folded_grapheme( + &self, + annotatins: &'a FoldAnnotations<'a>, + byte_idx: usize, + count: usize, + ) -> usize; + fn nth_next_folded_line( + &self, + annotations: &'a FoldAnnotations<'a>, + line_idx: usize, + count: usize, + ) -> usize; + fn nth_prev_folded_char( + &self, + annotations: &'a FoldAnnotations<'a>, + char_idx: usize, + count: usize, + ) -> usize; + fn nth_prev_folded_grapheme( + &self, + annotatins: &'a FoldAnnotations<'a>, + byte_idx: usize, + count: usize, + ) -> usize; + fn nth_prev_folded_line( + &self, + annotations: &'a FoldAnnotations<'a>, + line_idx: usize, + count: usize, + ) -> usize; +} + +impl<'a> RopeSliceFoldExt<'a> for RopeSlice<'a> { + fn folded_chars(&self, annotations: &'a FoldAnnotations<'a>) -> FoldedChars<'a> { + FoldedChars { + inner: FoldedTextItems::new(*self, annotations, 0), + } + } + + fn folded_graphemes(&self, annotations: &'a FoldAnnotations<'a>) -> FoldedGraphemes<'a> { + FoldedGraphemes { + inner: FoldedTextItems::new(*self, annotations, 0), + } + } + + fn folded_lines(&self, annotations: &'a FoldAnnotations<'a>) -> FoldedLines<'a> { + FoldedLines { + inner: FoldedTextItems::new(*self, annotations, 0), + } + } + + fn folded_chars_at( + &self, + annotations: &'a FoldAnnotations<'a>, + char_idx: usize, + ) -> FoldedChars<'a> { + FoldedChars { + inner: FoldedTextItems::new(*self, annotations, char_idx), + } + } + + fn folded_graphemes_at( + &self, + annotations: &'a FoldAnnotations<'a>, + byte_idx: usize, + ) -> FoldedGraphemes<'a> { + FoldedGraphemes { + inner: FoldedTextItems::new(*self, annotations, byte_idx), + } + } + + fn folded_lines_at( + &self, + annotations: &'a FoldAnnotations<'a>, + line_idx: usize, + ) -> FoldedLines<'a> { + FoldedLines { + inner: FoldedTextItems::new(*self, annotations, line_idx), + } + } + + fn next_folded_char(&self, annotations: &'a FoldAnnotations<'a>, char_idx: usize) -> usize { + self.nth_next_folded_char(annotations, char_idx, 1) + } + + fn next_folded_grapheme(&self, annotations: &'a FoldAnnotations<'a>, byte_idx: usize) -> usize { + self.nth_next_folded_grapheme(annotations, byte_idx, 1) + } + + fn next_folded_line(&self, annotations: &'a FoldAnnotations<'a>, line_idx: usize) -> usize { + self.nth_next_folded_line(annotations, line_idx, 1) + } + + fn prev_folded_char(&self, annotations: &'a FoldAnnotations<'a>, char_idx: usize) -> usize { + self.nth_prev_folded_char(annotations, char_idx, 1) + } + + fn prev_folded_grapheme(&self, annotations: &'a FoldAnnotations<'a>, byte_idx: usize) -> usize { + self.nth_prev_folded_grapheme(annotations, byte_idx, 1) + } + + fn prev_folded_line(&self, annotations: &'a FoldAnnotations<'a>, line_idx: usize) -> usize { + self.nth_prev_folded_line(annotations, line_idx, 1) + } + + fn nth_next_folded_char( + &self, + annotations: &'a FoldAnnotations<'a>, + char_idx: usize, + mut count: usize, + ) -> usize { + if count == 0 { + return char_idx; + } + + let mut chars = self.folded_chars_at(annotations, char_idx); + + // consume the initial char + chars.next(); + + // the initial char can be folded + if chars.last_idx().unwrap_or(self.len_chars()) != char_idx { + count -= 1; + } + + chars.by_ref().take(count).for_each(|_| ()); + chars + .last_idx() + .unwrap_or(self.len_chars().saturating_sub(1)) + } + + fn nth_next_folded_grapheme( + &self, + annotations: &'a FoldAnnotations<'a>, + byte_idx: usize, + mut count: usize, + ) -> usize { + if count == 0 { + return byte_idx; + } + + let mut graphemes = self.folded_graphemes_at(annotations, byte_idx); + + // consume the initial grapheme + graphemes.next(); + + // the initial grapheme can be folded + if graphemes.last_idx().unwrap_or(self.len_bytes()) != byte_idx { + count -= 1; + } + + graphemes.by_ref().take(count).for_each(|_| ()); + graphemes + .last_idx() + .unwrap_or(self.len_bytes().saturating_sub(1)) + } + + fn nth_next_folded_line( + &self, + annotations: &'a FoldAnnotations<'a>, + line_idx: usize, + mut count: usize, + ) -> usize { + if count == 0 { + return line_idx; + } + + let mut lines = self.folded_lines_at(annotations, line_idx); + + // consume the initial line + lines.next(); + + // the initial line can be folded + if lines.last_idx().unwrap_or(self.len_lines()) != line_idx { + count -= 1; + } + + lines.by_ref().take(count).for_each(|_| ()); + lines + .last_idx() + .unwrap_or(self.len_lines().saturating_sub(1)) + } + + fn nth_prev_folded_char( + &self, + annotations: &'a FoldAnnotations<'a>, + char_idx: usize, + count: usize, + ) -> usize { + if count == 0 { + return char_idx; + } + + let mut chars = self.folded_chars_at(annotations, char_idx).reversed(); + + chars.by_ref().take(count).for_each(|_| ()); + chars.last_idx().unwrap_or(0) + } + + fn nth_prev_folded_grapheme( + &self, + annotations: &'a FoldAnnotations<'a>, + byte_idx: usize, + count: usize, + ) -> usize { + if count == 0 { + return byte_idx; + } + + let mut graphemes = self.folded_graphemes_at(annotations, byte_idx).reversed(); + + graphemes.by_ref().take(count).for_each(|_| ()); + graphemes.last_idx().unwrap_or(0) + } + + fn nth_prev_folded_line( + &self, + annotations: &'a FoldAnnotations<'a>, + line_idx: usize, + count: usize, + ) -> usize { + if count == 0 { + return line_idx; + } + + let mut lines = self.folded_lines_at(annotations, line_idx).reversed(); + + lines.by_ref().take(count).for_each(|_| ()); + lines.last_idx().unwrap_or(0) + } +} + +macro_rules! FoldedWrapper { + ($Name:ident, $TextItems:ident) => { + pub struct $Name<'a> { + inner: FoldedTextItems<'a, $TextItems<'a>>, + } + + impl<'a> $Name<'a> { + pub fn reverse(&mut self) { + self.inner.is_reversed = !self.inner.is_reversed; + } + + pub fn reversed(mut self) -> Self { + self.reverse(); + self + } + + pub fn prev(&mut self) -> Option<::Item> { + self.inner.prev() + } + + pub fn last_idx(&self) -> Option { + self.inner.last_idx + } + } + + impl<'a> Iterator for $Name<'a> { + type Item = <$TextItems<'a> as Iterator>::Item; + + fn next(&mut self) -> Option { + self.inner.next() + } + } + }; +} + +FoldedWrapper!(FoldedChars, Chars); +FoldedWrapper!(FoldedGraphemes, RopeGraphemes); +FoldedWrapper!(FoldedLines, Lines); + +struct FoldedTextItems<'a, Items> { + items: Items, + slice: RopeSlice<'a>, + annotations: &'a FoldAnnotations<'a>, + idx: usize, + last_idx: Option, + is_reversed: bool, +} + +impl<'a, Items: TextItems<'a>> FoldedTextItems<'a, Items> { + fn new(slice: RopeSlice<'a>, annotations: &'a FoldAnnotations<'a>, idx: usize) -> Self { + Items::reset_pos(annotations, idx); + Self { + items: Items::at(slice, idx), + slice, + annotations, + idx, + last_idx: None, + is_reversed: false, + } + } + + #[inline(always)] + fn prev(&mut self) -> Option { + if !self.is_reversed { + self.prev_impl() + } else { + self.next_impl() + } + } + + fn prev_impl(&mut self) -> Option { + if self.idx == 0 { + self.last_idx = None; + return None; + } + + self.idx -= 1; + if let Some(position) = Items::consume_prev(self.annotations, self.idx) { + self.idx = position; + self.items = Items::at(self.slice, self.idx); + + return self.prev_impl(); + } + + self.last_idx = Some(self.idx); + + Some( + self.items + .prev_impl() + .expect("The `idx` field must equal the item index."), + ) + } + + fn next_impl(&mut self) -> Option { + if self.idx == Items::len(self.slice) { + self.last_idx = None; + return None; + } + + if let Some(position) = Items::consume_next(self.annotations, self.idx) { + self.idx = position + 1; + self.items = Items::at(self.slice, self.idx); + + return self.next_impl(); + } + + self.last_idx = Some(self.idx); + + let result = self + .items + .next_impl() + .expect("The `idx` field must equal the item index."); + self.idx += 1; + + Some(result) + } +} + +impl<'a, Items: TextItems<'a>> Iterator for FoldedTextItems<'a, Items> { + type Item = Items::Item; + + #[inline(always)] + fn next(&mut self) -> Option { + if !self.is_reversed { + self.next_impl() + } else { + self.prev_impl() + } + } +} + +trait TextItems<'a>: Iterator { + fn at(slice: RopeSlice<'a>, idx: usize) -> Self; + fn reset_pos(annotations: &FoldAnnotations, idx: usize); + fn len(slice: RopeSlice) -> usize; + fn prev_impl(&mut self) -> Option; + fn next_impl(&mut self) -> Option; + fn consume_prev(annotations: &FoldAnnotations, idx: usize) -> Option; + fn consume_next(annotations: &FoldAnnotations, idx: usize) -> Option; +} + +impl<'a> TextItems<'a> for Chars<'a> { + fn at(slice: RopeSlice<'a>, char_idx: usize) -> Self { + slice.chars_at(char_idx) + } + + fn reset_pos(annotations: &FoldAnnotations, char_idx: usize) { + annotations.reset_pos(char_idx, |fold| fold.start.char) + } + + fn len(slice: RopeSlice) -> usize { + slice.len_chars() + } + + fn prev_impl(&mut self) -> Option { + self.prev() + } + + fn next_impl(&mut self) -> Option { + self.next() + } + + fn consume_prev(annotations: &FoldAnnotations, char_idx: usize) -> Option { + annotations + .consume_prev(char_idx, |fold| fold.end.char) + .map(|fold| fold.start.char) + } + + fn consume_next(annotations: &FoldAnnotations, char_idx: usize) -> Option { + annotations + .consume_next(char_idx, |fold| fold.start.char) + .map(|fold| fold.end.char) + } +} + +impl<'a> TextItems<'a> for RopeGraphemes<'a> { + fn at(slice: RopeSlice<'a>, byte_idx: usize) -> Self { + slice.graphemes_at(byte_idx) + } + + fn reset_pos(annotations: &FoldAnnotations, byte_idx: usize) { + annotations.reset_pos(byte_idx, |fold| fold.start.byte) + } + + fn len(slice: RopeSlice) -> usize { + slice.len_bytes() + } + + fn prev_impl(&mut self) -> Option { + self.prev() + } + + fn next_impl(&mut self) -> Option { + self.next() + } + + fn consume_prev(annotations: &FoldAnnotations, byte_idx: usize) -> Option { + annotations + .consume_prev(byte_idx, |fold| fold.end.byte) + .map(|fold| fold.start.byte) + } + + fn consume_next(annotations: &FoldAnnotations, byte_idx: usize) -> Option { + annotations + .consume_next(byte_idx, |fold| fold.start.byte) + .map(|fold| fold.end.byte) + } +} + +impl<'a> TextItems<'a> for Lines<'a> { + fn at(slice: RopeSlice<'a>, line_idx: usize) -> Self { + slice.lines_at(line_idx) + } + + fn reset_pos(annotations: &FoldAnnotations, line_idx: usize) { + annotations.reset_pos(line_idx, |fold| fold.start.line) + } + + fn len(slice: RopeSlice) -> usize { + slice.len_lines() + } + + fn prev_impl(&mut self) -> Option { + self.prev() + } + + fn next_impl(&mut self) -> Option { + self.next() + } + + fn consume_prev(annotations: &FoldAnnotations, line_idx: usize) -> Option { + annotations + .consume_prev(line_idx, |fold| fold.end.line) + .map(|fold| fold.start.line) + } + + fn consume_next(annotations: &FoldAnnotations, line_idx: usize) -> Option { + annotations + .consume_next(line_idx, |fold| fold.start.line) + .map(|fold| fold.end.line) + } +} diff --git a/helix-core/src/text_folding/test.rs b/helix-core/src/text_folding/test.rs new file mode 100644 index 000000000000..1c1dda796fd6 --- /dev/null +++ b/helix-core/src/text_folding/test.rs @@ -0,0 +1,500 @@ +use crate::graphemes::next_grapheme_boundary; + +use super::*; + +use test_utils::new_fold_points; +use test_utils::{fold_points, fold_points_filtered_by}; +use test_utils::{folds_eq, folds_eq_by}; +use test_utils::{FOLDED_TEXT_SAMPLE, TEXT_SAMPLE}; + +#[test] +fn fold_text() { + _ = *FOLDED_TEXT_SAMPLE; +} + +#[test] +fn fold_container_from() { + let mut points = fold_points(); + // additional points will be removed + points.extend( + [("rm", 73, 77..=77)] + .into_iter() + .map(|(object, header_line, target_lines)| { + new_fold_points(*TEXT_SAMPLE, object, header_line, target_lines) + }), + ); + + let container = FoldContainer::from(*TEXT_SAMPLE, points.clone()); + + let partial_eq = |sfp1: &StartFoldPoint, sfp2: &StartFoldPoint| -> bool { + &sfp1.object == &sfp2.object && sfp1.header == sfp2.header && sfp1.target == sfp2.target + }; + assert!(container.start_points.iter().enumerate().all(|(i, sfp)| { + let (expected, _) = &points[i]; + if partial_eq(&sfp, expected) { + return true; + } + eprintln!( + "index = {i}\n\ + sfp = {sfp:#?}\n\ + expected = {expected:#?}" + ); + false + })); + + let partial_eq = + |efp1: &EndFoldPoint, efp2: &EndFoldPoint| -> bool { efp1.target == efp2.target }; + assert!(container.end_points.iter().enumerate().all(|(i, efp)| { + let (_, expected) = &points[efp.link]; + if partial_eq(&efp, expected) { + return true; + } + eprintln!( + "index = {i}\n\ + efp = {efp:#?}\n\ + expected = {expected:#?}" + ); + false + })); +} + +#[test] +fn fold_container_add() { + let mut points = fold_points(); + points.extend([]); + + let container = &mut FoldContainer::from( + *TEXT_SAMPLE, + points + .iter() + .cloned() + .enumerate() + .filter(|(i, _)| i % 2 == 0) + .map(|(_, points)| points) + .collect(), + ); + container.add( + *TEXT_SAMPLE, + points + .iter() + .cloned() + .enumerate() + .filter(|(i, _)| i % 2 != 0) + .map(|(_, points)| points) + .collect(), + ); + + let expected = &FoldContainer::from(*TEXT_SAMPLE, points); + assert!(folds_eq(container, expected)); +} + +#[test] +fn fold_container_replace() { + // replacements, replaced + let cases = [ + (&[0, 1][..], &[][..]), + (&[2][..], &[3, 4, 5, 6, 7, 8][..]), + (&[9][..], &[10, 11][..]), + (&[12][..], &[13][..]), + (&[14][..], &[15][..]), + (&[19][..], &[16, 17, 18][..]), + ]; + + for (case_idx, (replacements, replaced)) in cases.into_iter().enumerate() { + let container = &mut FoldContainer::from( + *TEXT_SAMPLE, + fold_points_filtered_by(|(i, _)| !replacements.contains(i)), + ); + container.replace( + *TEXT_SAMPLE, + fold_points_filtered_by(|(i, _)| replacements.contains(i)), + ); + + let expected = &FoldContainer::from( + *TEXT_SAMPLE, + fold_points_filtered_by(|(i, _)| !replaced.contains(i)), + ); + + assert!( + folds_eq_by( + container, + expected, + |sfp1, sfp2| sfp1 == sfp2, + |efp1, efp2| efp1.link == efp2.link, + ), + "case index = {case_idx}" + ); + } +} + +#[test] +fn fold_container_remove_by_selection() { + // line from, line to, removed + let cases = [ + (0, 0, &[][..]), + (2, 3, &[][..]), + (5, 6, &[][..]), + (6, 7, &[][..]), + (8, 8, &[2][..]), + (17, 19, &[2, 4][..]), + (21, 34, &[2, 5, 6, 9, 10, 11][..]), + (40, 42, &[12][..]), + (45, 55, &[12, 13, 15][..]), + ]; + + for (case_idx, (from, to, removed)) in cases.into_iter().enumerate() { + let selection = &Selection::single( + TEXT_SAMPLE.line_to_char(from), + next_grapheme_boundary(*TEXT_SAMPLE, TEXT_SAMPLE.line_to_char(to)), + ); + + let container = &mut FoldContainer::from(*TEXT_SAMPLE, fold_points()); + container.remove_by_selection(*TEXT_SAMPLE, selection); + + let expected = &FoldContainer::from( + *TEXT_SAMPLE, + fold_points_filtered_by(|(i, _)| !removed.contains(i)), + ); + + assert!(folds_eq(container, expected), "case index = {case_idx}"); + } +} + +#[test] +fn fold_container_throw_range_out_of_folds() { + let container = &FoldContainer::from(*TEXT_SAMPLE, fold_points()); + + // line from, line to, expected (line from, line to) + let cases = [ + ((1, 1), Range::new(0, 16)), // (0, 0) + ((4, 4), Range::new(34, 50)), // (3, 3) + ((1, 4), Range::new(0, 50)), // (0, 3) + ((19, 63), Range::new(67, 827)), // (6, 62) + ((44, 10), Range::new(576, 67)), // (39, 6) + ((77, 45), Range::new(1009, 558)), // (72, 39) + ]; + + for (case_idx, ((from, to), expected)) in cases.into_iter().enumerate() { + let range = Range::new( + TEXT_SAMPLE.line_to_char(from), + line_end_char_index(&*TEXT_SAMPLE, to), + ); + + let result = container.throw_range_out_of_folds(*TEXT_SAMPLE, range); + let expected = expected.with_direction(result.direction()); + + assert_eq!(result, expected, "case index = {case_idx}"); + } +} + +#[test] +fn fold_container_find() { + let container = &FoldContainer::from(*TEXT_SAMPLE, fold_points()); + + // object, block line range, expected + let cases = [ + ("0", 1..=1, Some(0)), + ("a", 1..=1, None), + ("0", 1..=2, None), + ("7", 28..=29, Some(7)), + ("6", 20..=22, Some(6)), + ("2", 8..=29, Some(2)), + ("10", 33..=35, Some(10)), + ]; + + for (case_idx, (object, block, expected)) in cases.into_iter().enumerate() { + let result = container.find(&FoldObject::TextObject(object), &block, |fold| { + fold.start.line..=fold.end.line + }); + let expected = expected.map(|idx| container.start_points[idx].fold(container)); + assert_eq!(result, expected, "case index = {case_idx}"); + } +} + +#[test] +fn fold_container_start_points_in_range() { + let container = &FoldContainer::from(*TEXT_SAMPLE, fold_points()); + + // block line range, expected + let cases = [ + (0..=0, None), + (6..=40, Some(2..=11)), + (10..=15, Some(3..=3)), + (55..=70, Some(16..=19)), + (0..=9, Some(0..=2)), + ]; + + for (case_idx, (block, expected)) in cases.into_iter().enumerate() { + let result = container.start_points_in_range(&block, |sfp| sfp.line); + let expected = expected.map_or(&[][..], |range| &container.start_points[range]); + assert_eq!(result, expected, "case index = {case_idx}"); + } +} + +#[test] +fn fold_container_fold_containing() { + let container = &FoldContainer::from(*TEXT_SAMPLE, fold_points()); + + // line, expected + let cases = [ + (0, None), + (1, Some(0)), + (7, None), + (11, Some(3)), + (9, Some(2)), + (57, None), + (78, Some(21)), + (12, Some(2)), + (19, Some(2)), + ]; + + for (case_idx, (line, expected)) in cases.into_iter().enumerate() { + let result = container.fold_containing(line, |fold| fold.start.line..=fold.end.line); + let expected = expected.map(|idx| container.start_points[idx].fold(container)); + assert_eq!(result, expected, "case index = {case_idx}"); + } +} + +#[test] +fn fold_container_superest_fold_containing() { + let container = &FoldContainer::from(*TEXT_SAMPLE, fold_points()); + + // line, expected + let cases = [ + (0, None), + (1, Some(0)), + (7, None), + (11, Some(2)), + (9, Some(2)), + (57, None), + (78, Some(21)), + (12, Some(2)), + (19, Some(2)), + ]; + + for (case_idx, (line, expected)) in cases.into_iter().enumerate() { + let result = + container.superest_fold_containing(line, |fold| fold.start.line..=fold.end.line); + let expected = expected.map(|idx| container.start_points[idx].fold(container)); + assert_eq!(result, expected, "case index = {case_idx}"); + } +} + +#[test] +fn fold_annotations_folded_lines_between() { + let container = &FoldContainer::from(*TEXT_SAMPLE, fold_points()); + let annotations = FoldAnnotations::new(Some(container)); + + // line range, expected + let cases = [ + (0..=0, 0), + (3..=3, 0), + (0..=3, 1), + (0..=5, 2), + (5..=7, 0), + (5..=30, 22), + (30..=31, 0), + (30..=51, 13), + (51..=51, 0), + (62..=79, 5), + ]; + + for (case_idx, (line_range, expected)) in cases.into_iter().enumerate() { + let result = annotations.folded_lines_between(&line_range); + assert_eq!(result, expected, "case index = {case_idx}"); + } +} + +#[test] +fn fold_container_update_by_transaction() { + use crate::Rope; + use crate::Transaction; + use std::cell::RefCell; + use std::iter::once; + + let init_container = &FoldContainer::from(*TEXT_SAMPLE, fold_points()); + let container = RefCell::new(FoldContainer::from(*TEXT_SAMPLE, fold_points())); + + let object_eq = |fold: Fold, object: &str| { + matches!( + fold.object(), + FoldObject::TextObject(textobject) if *textobject == object + ) + }; + + let decrease_eq = |n: usize| n == init_container.len() - container.borrow().len(); + + // a change, an assert function + let cases: Vec<(_, Box)> = vec![ + ( + // remove the first header char + (0, 1, None), + Box::new(|| { + let container = container.borrow(); + let fold = container.start_points[0].fold(&container); + + assert!( + fold.header() == 0 && object_eq(fold, "0") && decrease_eq(0), + "fold = {fold:#?}" + ); + }), + ), + ( + // replace the text "丂 line index: " from the 0i line + (0, 15, Some("new header".into())), + Box::new(|| { + let container = container.borrow(); + let fold = container.start_points[0].fold(&container); + + assert!(object_eq(fold, "0") && decrease_eq(0), "fold = {fold:#?}"); + }), + ), + ( + // replace the trimmed 0i line + (0, 16, Some("new header".into())), + Box::new(|| { + let container = container.borrow(); + let fold = container.start_points[0].fold(&container); + + assert!(object_eq(fold, "1") && decrease_eq(1), "fold = {fold:#?}"); + }), + ), + ( + // remove the entire 0i line + (0, 17, None), + Box::new(|| { + let container = container.borrow(); + let fold = container.start_points[0].fold(&container); + + assert!(object_eq(fold, "1") && decrease_eq(1), "fold = {fold:#?}"); + }), + ), + ( + // remove the first nonwhitespace char of 11i line + (137, 138, None), + Box::new(|| { + let container = container.borrow(); + let fold = container.start_points[3].fold(&container); + + assert!(object_eq(fold, "4") && decrease_eq(1), "fold = {fold:#?}"); + }), + ), + ( + // remove the last nonwhitespace char of the 19i line + (263, 264, None), + Box::new(|| { + let container = container.borrow(); + let fold = container.start_points[4].fold(&container); + + assert!(object_eq(fold, "5") && decrease_eq(1), "fold = {fold:#?}"); + }), + ), + ( + // remove the 33i entire line + (486, 504, None), + Box::new(|| { + let container = container.borrow(); + let fold = container.start_points[9].fold(&container); + + assert!(object_eq(fold, "9") && decrease_eq(2), "fold = {fold:#?}"); + }), + ), + ( + // remove the last nonwhitespace char of the 18i line + (263, 264, None), + Box::new(|| { + let container = container.borrow(); + let fold = container.start_points[4].fold(&container); + + assert!( + object_eq(fold, "5") && fold.start.line == 19 && decrease_eq(1), + "fold = {fold:#?}" + ); + }), + ), + ( + // remove the 9i entire line + (117, 136, None), + Box::new(|| { + let container = container.borrow(); + let fold = container.start_points[3].fold(&container); + + assert!(object_eq(fold, "3") && decrease_eq(0), "fold = {fold:#?}"); + }), + ), + ( + // replace the text "19 乪\n\t" of the 19i-20i lines + (279, 285, Some("new text\n\t".into())), + Box::new(|| { + let container = container.borrow(); + let fold = container.start_points[6].fold(&container); + + assert!(object_eq(fold, "6") && decrease_eq(0), "fold = {fold:#?}"); + }), + ), + ( + // replace the text "19 乪\n\t\tline" of the 19i-20i lines + (279, 292, Some("new text\n\t\tnew text".into())), + Box::new(|| { + let container = container.borrow(); + let fold = container.start_points[6].fold(&container); + + assert!(object_eq(fold, "7") && decrease_eq(1), "fold = {fold:#?}"); + }), + ), + ( + // remove the line ending of the 55i line and 56i-57i lines + (737, 740, None), + Box::new(|| { + let container = container.borrow(); + let fold = container.start_points[15].fold(&container); + + assert!( + object_eq(fold, "15") && decrease_eq(0) && fold.end.line == 54, + "fold = {fold:#?}" + ); + }), + ), + ( + // remove the line ending of the 33i line + (502, 503, None), + Box::new(|| { + let container = container.borrow(); + let fold = container.start_points[11].fold(&container); + + assert!( + object_eq(fold, "11") && decrease_eq(0) && fold.end.line == 34, + "fold = {fold:#?}" + ) + }), + ), + ( + // remove the entire 39i-40i lines + (558, 576, None), + Box::new(|| { + let container = container.borrow(); + let fold = container.start_points[12].fold(&container); + + assert!( + object_eq(fold, "13") && decrease_eq(1) && fold.is_superest(), + "fold = {fold:#?}" + ) + }), + ), + ]; + + for (change, assert) in cases { + let doc = &mut Rope::from(*TEXT_SAMPLE); + // reset container + *container.borrow_mut() = init_container.clone(); + + let transaction = &Transaction::change(doc, once(change)); + transaction.apply(doc); + // update container + container + .borrow_mut() + .update_by_transaction(doc.slice(..), *TEXT_SAMPLE, transaction); + + assert(); + } +} diff --git a/helix-core/src/text_folding/test_utils.rs b/helix-core/src/text_folding/test_utils.rs new file mode 100644 index 000000000000..7a7674a21ea4 --- /dev/null +++ b/helix-core/src/text_folding/test_utils.rs @@ -0,0 +1,176 @@ +use std::fs; +use std::ops; +use std::sync::LazyLock; + +use helix_stdx::rope::RopeSliceExt; + +use crate::{text_folding::FoldObject, RopeSlice}; + +use super::{EndFoldPoint, Fold, FoldContainer, StartFoldPoint}; + +pub(crate) static TEXT_SAMPLE: LazyLock = LazyLock::new(|| { + const PATH: &str = "src/text_folding/test_utils/text-sample.txt"; + RopeSlice::from(fs::read_to_string(PATH).unwrap().leak() as &str) +}); + +// INFO: to update the text set the envaroment variable HELIX_UPDATE_FOLDED_SIMPLE_TEXT +pub(crate) static FOLDED_TEXT_SAMPLE: LazyLock = LazyLock::new(|| { + use std::fmt::Write; + + use crate::doc_formatter::{DocumentFormatter, TextFormat}; + use crate::graphemes::Grapheme; + use crate::text_annotations::TextAnnotations; + + const PATH: &str = "src/text_folding/test_utils/folded-text-sample"; + const VAR: &str = "HELIX_UPDATE_FOLDED_SIMPLE_TEXT"; + + let container = &FoldContainer::from(*TEXT_SAMPLE, fold_points()); + + let text_format = &TextFormat::default(); + let annotations = &mut TextAnnotations::default(); + annotations.add_folds(container); + + let formatter = + DocumentFormatter::new_at_prev_checkpoint(*TEXT_SAMPLE, text_format, &annotations, 0); + + let mut folded_text = String::new(); + for g in formatter { + match g.raw { + Grapheme::Newline => write!(folded_text, "\n").unwrap(), + Grapheme::Tab { width: _ } => write!(folded_text, "\t").unwrap(), + Grapheme::Other { g } => write!(folded_text, "{g}").unwrap(), + } + } + // remove EOf + folded_text.remove(folded_text.len() - 1); + + match std::env::var(VAR) { + Ok(_) => fs::write(PATH, &folded_text).unwrap(), + Err(_) => assert_eq!(folded_text, fs::read_to_string(PATH).unwrap()), + } + + RopeSlice::from(folded_text.leak() as &str) +}); + +pub(crate) fn new_fold_points( + text: RopeSlice, + object: &'static str, + header_line: usize, + target_lines: ops::RangeInclusive, +) -> (StartFoldPoint, EndFoldPoint) { + let object = FoldObject::TextObject(object); + let header = text.line_to_char(header_line) + + text.line(header_line).first_non_whitespace_char().unwrap(); + let target = { + let (from, to) = (*target_lines.start(), *target_lines.end()); + let start = text.line_to_char(from) + text.line(from).first_non_whitespace_char().unwrap(); + let end = text.line_to_char(to) + text.line(to).last_non_whitespace_char().unwrap(); + start..=end + }; + Fold::new_points(text, object, header, &target) +} + +pub(crate) fn fold_points() -> Vec<(StartFoldPoint, EndFoldPoint)> { + // object, header line, target lines + [ + ("0", 0, 1..=1), + ("1", 3, 4..=4), + ("2", 6, 8..=29), + ("3", 8, 10..=11), + ("4", 15, 16..=18), + ("5", 14, 19..=25), // block: 20..=25 + ("6", 19, 20..=22), + ("7", 27, 28..=29), + ("8", 28, 29..=29), + ("9", 31, 32..=36), + ("10", 32, 33..=35), + ("11", 33, 34..=35), + ("12", 39, 41..=45), + ("13", 41, 43..=45), + ("14", 46, 48..=50), + ("15", 46, 52..=55), + ("16", 58, 59..=59), + ("17", 60, 61..=61), + ("18", 62, 63..=63), + ("19", 58, 66..=67), + ("20", 74, 76..=76), + ("21", 72, 78..=78), + ] + .into_iter() + .map(|(object, header_line, target_lines)| { + new_fold_points(*TEXT_SAMPLE, object, header_line, target_lines) + }) + .collect() +} + +pub(crate) fn fold_points_filtered_by( + f: impl Fn(&(usize, (StartFoldPoint, EndFoldPoint))) -> bool, +) -> Vec<(StartFoldPoint, EndFoldPoint)> { + fold_points() + .into_iter() + .enumerate() + .filter(f) + .map(|(_, points)| points) + .collect() +} + +pub(crate) fn folds_eq(container1: &FoldContainer, container2: &FoldContainer) -> bool { + folds_eq_by( + container1, + container2, + |sfp1, sfp2| sfp1 == sfp2, + |efp1, efp2| efp1 == efp2, + ) +} + +pub(crate) fn folds_eq_by( + container1: &FoldContainer, + container2: &FoldContainer, + sfp_eq: impl Fn(&StartFoldPoint, &StartFoldPoint) -> bool, + efp_eq: impl Fn(&EndFoldPoint, &EndFoldPoint) -> bool, +) -> bool { + if container1.len() != container2.len() { + eprintln!( + "left has lenght = {}\n\ + right has lenght = {}", + container1.len(), + container2.len(), + ); + return false; + } + + container1 + .start_points + .iter() + .zip(&container2.start_points) + .enumerate() + .all(|(i, (sfp1, sfp2))| { + if sfp_eq(sfp1, sfp2) { + return true; + } + + eprintln!( + "index = {i}\n\ + left sfp = {sfp1:#?}\n\ + right sfp = {sfp2:#?}" + ); + false + }) + && container1 + .end_points + .iter() + .zip(&container2.end_points) + .enumerate() + .all(|(i, (efp1, efp2))| { + if efp_eq(efp1, efp2) { + return true; + } + + eprintln!( + "index = {i}\n\ + left efp = {efp1:#?}\n\ + right efp = {efp2:#?}" + ); + false + }) +} diff --git a/helix-core/src/text_folding/test_utils/folded-text-sample b/helix-core/src/text_folding/test_utils/folded-text-sample new file mode 100644 index 000000000000..0b26e654349f --- /dev/null +++ b/helix-core/src/text_folding/test_utils/folded-text-sample @@ -0,0 +1,32 @@ +丂 line index: 0 + + 丅 line index: 3 + +丏 line index: 6 +line index: 7 乕 + +line index: 31 亃 + + +line index: 39 伬 + +line index: 46 伳 + + + + +line num_ber: 58 伻 +line_index: 60 伻 +line_index: 62 伻 + + +line_index: 68 伻 +齞line_index: 69 伻 +line_index: 70 伻 + +齠line_index: 72 伻 + line_index: 73 伻 + 齢line_index: 74 伻 + line_index: 75 伻 + line_index: 77 伻 +line_index: 79 \ No newline at end of file diff --git a/helix-core/src/text_folding/test_utils/text-sample.txt b/helix-core/src/text_folding/test_utils/text-sample.txt new file mode 100644 index 000000000000..a0fd12ac58e8 --- /dev/null +++ b/helix-core/src/text_folding/test_utils/text-sample.txt @@ -0,0 +1,80 @@ +丂 line index: 0 +line index: 1 乄 + + 丅 line index: 3 +line index: 4 乊 + +丏 line index: 6 +line index: 7 乕 + 丗 line index: 8 + line index: 9 乚 + 丠 line index: 10 + line index: 11 乢 + + + 丣 line index: 14 + line index: 15 乤 + 丩 line index: 16 + line index: 17 乧 + 丯 line index: 18 + line index: 19 乪 + 丳 line index: 20 + line index: 21 乬 + 丷 line index: 22 + + line index: 24 乮 + 乀 line index: 25 + line index: 26 乿 + 乴 line index: 27 +line index: 28 亁 +乶 line index: 29 + +line index: 31 亃 + 乸 line index: 32 +line index: 33 亅 + 乺 line index: 34 +line index: 35 亊 + 伇 line index: 36 + + +line index: 39 伬 + +伋 line index: 41 + +line index: 43 伮 + +伒 line index: 45 +line index: 46 伳 + + 伔 line index: 48 + + line index: 50 伷 + + 伖 line index: 52 + + line index: 54 伻 +齔line_index: 55 + + +line num_ber: 58 伻 +齖line_num_ber: 59 +line_index: 60 伻 +齘line_index: 61 +line_index: 62 伻 +齚line_index: 63 + + +line_index: 66 伻 +齜line_index: 67 伻 +line_index: 68 伻 +齞line_index: 69 伻 +line_index: 70 伻 + +齠line_index: 72 伻 + line_index: 73 伻 + 齢line_index: 74 伻 + line_index: 75 伻 +齤line_index: 76 伻 + line_index: 77 伻 + 齦line_index: 78 伻 +line_index: 79 \ No newline at end of file diff --git a/helix-core/src/text_folding/transaction.rs b/helix-core/src/text_folding/transaction.rs new file mode 100644 index 000000000000..2f3ee93f139d --- /dev/null +++ b/helix-core/src/text_folding/transaction.rs @@ -0,0 +1,289 @@ +//! Processing of folds when a transaction is applied. +//! +//! During a transaction, the fold container removes disturbed folds. +//! +//! Disturbed folds are folds that: +//! 1. header has been mixed with the outer text +//! 2. header has been completely removed +//! 3. header and target have been mixed +//! 4. start char of target has been removed +//! 5. end char of target has been removed +//! +//! After that, the fold container normalizes its folds. + +use std::cmp::{max, min}; +use std::iter::once; +use std::ops; + +use ropey::RopeSlice; + +use crate::ChangeSet; +use crate::{graphemes::prev_grapheme_boundary, transaction::UpdatePosition, Transaction}; + +use super::FoldContainer; + +impl FoldContainer { + pub fn update_by_transaction( + &mut self, + new_text: RopeSlice, + old_text: RopeSlice, + transaction: &Transaction, + ) { + let disturbed = self.disturbed_folds(old_text, transaction); + let mut sort = !disturbed.is_empty(); + + self.delete(disturbed); + + self.update(new_text, transaction.changes()); + + let removables = self.normalize(new_text); + sort |= !removables.is_empty(); + + self.delete(removables); + + if sort { + self.sort_end_points(); + self.set_super_links(); + } + } + + /// Returns the start indices of folds that have been disturbed when the transaction is applied. + fn disturbed_folds(&self, text: RopeSlice, transaction: &Transaction) -> Vec { + let mut disturbed: Vec<_> = transaction + .changes_iter() + .filter_map(|(from, to, fragment)| { + // an insertion disturbs no folds + if from == to { + return None; + } + + let change_range = from..=to - 1; + + // the range of potentially disturbed folds + let range = { + let start = { + let start_fold = self + .end_points + .get(self.end_points.partition_point(|efp| { + max(efp.target, efp.char) < *change_range.start() + })) + .map(|efp| efp.fold(self))?; + start_fold + .superest_fold(self) + .unwrap_or(start_fold) + .start_idx() + }; + let end = start + + self.start_points[start..] + .partition_point(|sfp| sfp.header <= *change_range.end()); + start..end + }; + + Some( + self.start_points[range] + .iter() + .map(|sfp| sfp.fold(self)) + .filter_map(move |fold| { + // returns the overlapping range of the passed `range` and `change_range` + let overlap = |range: &ops::RangeInclusive<_>| { + let start = max(*range.start(), *change_range.start()); + let end = min(*range.end(), *change_range.end()); + start..=end + }; + + let header = { + let start = fold.header(); + let end = prev_grapheme_boundary(text, fold.start.target); + start..=end + }; + let target = { + let start = fold.start.target; + let end = fold.end.target; + start..=end + }; + + let header_overlap = overlap(&header); + let target_overlap = overlap(&target); + + // 1. header has been mixed with the outer text + if !header_overlap.is_empty() + && change_range.start() < header.start() + && fragment.is_some() + { + return Some(fold.start_idx()); + } + + // 2. header has been completely removed + if header_overlap == header && fragment.is_none() { + return Some(fold.start_idx()); + } + + // 3. header and target have been mixed + if !header_overlap.is_empty() && !target_overlap.is_empty() { + return Some(fold.start_idx()); + } + + // 4. start char of target has been removed + if target_overlap.contains(target.start()) { + return Some(fold.start_idx()); + } + + // 5. end char of target has been removed + if target_overlap.contains(target.end()) { + return Some(fold.start_idx()); + } + + None + }), + ) + }) + .flatten() + .collect(); + + disturbed.sort(); + disturbed.dedup(); + + disturbed + } + + /// Updates headers and targets. + fn update(&mut self, new_text: RopeSlice, changes: &ChangeSet) { + use Component::*; + + let mut start_points = self.start_points.iter_mut().peekable(); + let mut end_points = self.end_points.iter_mut().peekable(); + + // create a partially sorted positions iterator for a fast update + let sorted_positions = + std::iter::from_fn(move || match (start_points.peek(), end_points.peek()) { + (None, None) => None, + + (Some(sfp), efp) if efp.map_or(true, |efp| sfp.header < efp.target) => { + start_points.next().map(|sfp| { + once(Header.update(&mut sfp.header)) + .chain(Some(StartTarget.update(&mut sfp.target))) + }) + } + + (_, Some(_)) => end_points + .next() + .map(|efp| once(EndTarget.update(&mut efp.target)).chain(None)), + + _ => unreachable!("Patterns must be exhausted."), + }) + .flatten(); + + changes.update_positions_with_helper(new_text, sorted_positions); + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Component { + Header, + StartTarget, + EndTarget, +} + +impl Component { + fn update(self, position: &mut usize) -> ComponentUpdater<'_> { + ComponentUpdater { + component: self, + position, + } + } +} + +struct ComponentUpdater<'a> { + component: Component, + position: &'a mut usize, +} + +impl<'a> UpdatePosition> for ComponentUpdater<'a> { + fn get_pos(&self) -> usize { + *self.position + } + + fn set_pos(&mut self, new_pos: usize) { + *self.position = new_pos; + } + + fn insert(&mut self, new_pos: usize, fragment: &str, text: &mut RopeSlice<'a>) { + use Component::*; + + // abc -> aXYbc + // before insertion -> a + // start of insertion -> X + // end of insertion -> Y + // after insertion -> b + #[rustfmt::skip] + match self.component { + // before insertion + EndTarget + => self.set_pos(prev_grapheme_boundary(*text, new_pos)), + + // start of insertion + // _ => self.set_pos(new_pos), + + // end of insertion + // _ => self.set_pos(prev_grapheme_boundary(*text, new_pos + fragment.chars().count())), + + // after insertion + Header + | StartTarget + => self.set_pos(new_pos + fragment.chars().count()), + }; + } + + fn delete(&mut self, _: usize, _: usize, new_pos: usize, text: &mut RopeSlice) { + use Component::*; + + // abc -> ac + // before deletion -> a + // after deletion -> c + #[rustfmt::skip] + match self.component { + // before deletion + StartTarget + => self.set_pos(prev_grapheme_boundary(*text, new_pos)), + + // after deletion + Header + | EndTarget + => self.set_pos(new_pos), + }; + } + + fn replace( + &mut self, + _: usize, + _: usize, + new_pos: usize, + fragment: &str, + text: &mut RopeSlice, + ) { + use Component::*; + + // abc -> aXYc + // before replacement -> a + // start of replacement -> X + // end of replacement -> Y + // after replacement -> c + #[rustfmt::skip] + match self.component { + // before replacement + // _ => self.set_pos(prev_grapheme_boundary(*text, new_pos)), + + // start of replacement + Header + | StartTarget + => self.set_pos(new_pos), + + // end of replacement + EndTarget + => self.set_pos(prev_grapheme_boundary(*text, new_pos + fragment.chars().count())), + + // after replacement + // _ => self.set_pos(new_pos + fragment.chars().count()), + }; + } +} diff --git a/helix-core/src/transaction.rs b/helix-core/src/transaction.rs index 41faf8f7f5e0..83dcd87db146 100644 --- a/helix-core/src/transaction.rs +++ b/helix-core/src/transaction.rs @@ -371,6 +371,16 @@ impl ChangeSet { self.changes.is_empty() || self.changes == [Operation::Retain(self.len)] } + /// # Warning + /// Unstable API, used to update `FoldContainer`. + pub fn update_positions_with_helper( + &self, + helper: Helper, + items: impl Iterator>, + ) { + PositionsUpdateIterator::new(self, helper, items).for_each(|_| ()); + } + /// Map a (mostly) *sorted* list of positions through the changes. /// /// This is equivalent to updating each position with `map_pos`: @@ -525,6 +535,272 @@ impl ChangeSet { } } +/// # Warning +/// Unstable API, used to update `FoldContainer`. +pub trait UpdatePosition { + fn get_pos(&self) -> usize; + fn set_pos(&mut self, new_pos: usize); + + fn retain(&mut self, old_pos: usize, new_pos: usize, _helper: &mut Helper) { + self.set_pos(new_pos + (self.get_pos() - old_pos)) + } + + fn delete(&mut self, len: usize, old_pos: usize, new_pos: usize, helper: &mut Helper); + + fn insert(&mut self, new_pos: usize, fragment: &str, helper: &mut Helper); + + fn replace( + &mut self, + len: usize, + old_pos: usize, + new_pos: usize, + fragment: &str, + helper: &mut Helper, + ); +} + +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +enum Update { + Retention, + Deletion, + Insertion, + Replacement, + Rest, +} + +struct PositionsUpdateIterator<'a, Iter, Item, Helper> { + changes: &'a [Operation], + changes_iter: std::iter::Enumerate>, + peeked_change: Option<(usize, &'a Operation)>, + + items: Iter, + retained_item: Option, + + update: Option, + + len: usize, + old_pos: usize, + new_pos: usize, + fragment: &'a str, + + helper: Helper, + + change_idx: usize, +} + +impl<'a, Iter, Item, Helper> PositionsUpdateIterator<'a, Iter, Item, Helper> +where + Iter: Iterator, + Item: UpdatePosition, +{ + fn new(change_set: &'a ChangeSet, helper: Helper, items: Iter) -> Self { + Self { + changes: change_set.changes(), + changes_iter: change_set.changes().iter().enumerate(), + peeked_change: None, + items, + retained_item: None, + update: None, + len: 0, + old_pos: 0, + new_pos: 0, + fragment: "", + helper, + change_idx: 0, + } + } + + fn next_update(&mut self) -> Update { + let Some((change_idx, change)) = self.next_change() else { + self.change_idx = self.changes.len(); + return Update::Rest; + }; + + self.change_idx = change_idx; + + match change { + Operation::Retain(len) => { + self.len = *len; + Update::Retention + } + Operation::Delete(len) => { + self.len = *len; + Update::Deletion + } + Operation::Insert(fragment) => { + self.fragment = fragment; + + if let Some(len) = self + .peek_change() + .and_then(|(_, operation)| match operation { + Operation::Delete(len) => Some(*len), + _ => None, + }) + { + // consume the delete operation + self.peeked_change.take(); + + self.len = len; + Update::Replacement + } else { + self.len = 0; + Update::Insertion + } + } + } + } + + fn update(&mut self, update: Update) -> Option { + use Update::*; + + let mut item = self.next_item()?; + + if item.get_pos() < self.old_pos { + return self.revert(item); + } + + match update { + Retention if item.get_pos() < self.old_pos + self.len => { + item.retain(self.old_pos, self.new_pos, &mut self.helper); + Some(item) + } + Insertion if item.get_pos() == self.old_pos => { + item.insert(self.new_pos, self.fragment, &mut self.helper); + Some(item) + } + Deletion if item.get_pos() < self.old_pos + self.len => { + item.delete(self.len, self.old_pos, self.new_pos, &mut self.helper); + Some(item) + } + Replacement if item.get_pos() < self.old_pos + self.len => { + item.replace( + self.len, + self.old_pos, + self.new_pos, + self.fragment, + &mut self.helper, + ); + Some(item) + } + Rest if item.get_pos() == self.old_pos => { + item.set_pos(self.new_pos); + Some(item) + } + _ => { + self.retain_item(item); + + if update != Rest { + self.old_pos += self.len; + } + + match update { + Retention => self.new_pos += self.len, + Insertion | Replacement => { + self.new_pos += self.fragment.chars().count(); + } + _ => (), + } + + None + } + } + } + + fn revert(&mut self, item: Item) -> Option { + for (change_idx, change) in self.changes[..self.change_idx].iter().enumerate().rev() { + match change { + Operation::Retain(len) => { + self.old_pos -= len; + self.new_pos -= len; + } + Operation::Delete(len) => { + self.old_pos -= len; + } + Operation::Insert(fragment) => { + self.new_pos -= fragment.chars().count(); + } + } + if self.old_pos <= item.get_pos() { + self.changes_iter = self.changes[change_idx..].iter().enumerate(); + } + } + + debug_assert!( + self.old_pos <= item.get_pos(), + "Reverse Iter across changeset works" + ); + + self.retain_item(item); + self.peeked_change = None; + self.update = None; + + self.next() + } + + fn next_change(&mut self) -> Option<(usize, &'a Operation)> { + self.peeked_change + .take() + .or_else(|| self.changes_iter.next()) + } + + fn peek_change(&mut self) -> Option<(usize, &Operation)> { + if self.peeked_change.is_none() { + self.peeked_change = Some(self.changes_iter.next()?); + } + self.peeked_change + } + + fn next_item(&mut self) -> Option { + self.retained_item.take().or_else(|| self.items.next()) + } + + fn retain_item(&mut self, item: Item) { + self.retained_item = Some(item); + } + + fn items_is_exhausted(&mut self) -> bool { + self.next_item().map_or(true, |item| { + self.retain_item(item); + false + }) + } + + fn panic_items_are_out_of_range(&mut self) -> ! { + let out_of_bounds = + std::iter::from_fn(|| self.next_item().map(|item| item.get_pos())).collect::>(); + panic!( + "Positions {out_of_bounds:?} are out of range for changeset len {}!", + self.old_pos + ) + } +} + +impl<'a, Iter, Item, Helper> Iterator for PositionsUpdateIterator<'a, Iter, Item, Helper> +where + Iter: Iterator, + Item: UpdatePosition, +{ + type Item = Item; + + fn next(&mut self) -> Option { + let update = self.update.unwrap_or_else(|| { + let update = self.next_update(); + self.update = Some(update); + update + }); + match self.update(update) { + None if !self.items_is_exhausted() => { + if update == Update::Rest { + self.panic_items_are_out_of_range() + } + self.update = None; + self.next() + } + result => result, + } + } +} + /// Transaction represents a single undoable unit of changes. Several changes can be grouped into /// a single transaction. #[derive(Debug, Default, Clone, PartialEq, Eq)] diff --git a/helix-term/src/application.rs b/helix-term/src/application.rs index 8c1db6499080..0cb5a7417223 100644 --- a/helix-term/src/application.rs +++ b/helix-term/src/application.rs @@ -199,7 +199,10 @@ impl Application { nr_of_files -= 1; doc_id } - Ok(doc_id) => doc_id, + Ok(doc_id) => { + ui::default_folding(&mut editor); + doc_id + } }; // with Action::Load all documents have the same view // NOTE: this isn't necessarily true anymore. If diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index 430d4430aaeb..2302e39a10a6 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -26,19 +26,23 @@ use helix_core::{ comment, doc_formatter::TextFormat, encoding, find_workspace, - graphemes::{self, next_grapheme_boundary}, + graphemes::{ + self, next_folded_grapheme_boundary, next_grapheme_boundary, prev_folded_grapheme_boundary, + prev_grapheme_boundary, + }, history::UndoKind, increment, indent::{self, IndentStyle}, - line_ending::{get_line_ending_of_str, line_end_char_index}, + line_ending::{get_line_ending_of_str, line_end_char_index, rope_is_line_ending}, match_brackets, movement::{self, move_vertically_visual, Direction}, object, pos_at_coords, regex::{self, Regex}, - search::{self, CharMatcher}, + search::{self, GraphemeMatcher}, selection, surround, syntax::config::{BlockCommentToken, LanguageServerFeature}, text_annotations::{Overlay, TextAnnotations}, + text_folding::{self, RopeSliceFoldExt}, textobject, unicode::width::UnicodeWidthChar, visual_offset_from_block, Deletion, LineEnding, Position, Range, Rope, RopeReader, RopeSlice, @@ -615,6 +619,9 @@ impl MappableCommand { goto_prev_tabstop, "Goto next snippet placeholder", rotate_selections_first, "Make the first selection your primary one", rotate_selections_last, "Make the last selection your primary one", + fold, "Fold text objects", + unfold, "Unfold text objects", + toggle_fold, "Toggle fold for the text object at the primary cursor", ); } @@ -1179,16 +1186,17 @@ fn goto_window_bottom(cx: &mut Context) { fn move_word_impl(cx: &mut Context, move_fn: F) where - F: Fn(RopeSlice, Range, usize) -> Range, + F: Fn(RopeSlice, &TextAnnotations, Range, usize) -> Range, { let count = cx.count(); let (view, doc) = current!(cx.editor); let text = doc.text().slice(..); + let annotations = view.text_annotations(doc, None); let selection = doc .selection(view.id) .clone() - .transform(|range| move_fn(text, range, count)); + .transform(move |range| move_fn(text, &annotations, range, count)); doc.set_selection(view.id, selection); } @@ -1242,12 +1250,13 @@ fn move_next_sub_word_end(cx: &mut Context) { fn goto_para_impl(cx: &mut Context, move_fn: F) where - F: Fn(RopeSlice, Range, usize, Movement) -> Range + 'static, + F: Fn(RopeSlice, &TextAnnotations, Range, usize, Movement) -> Range + 'static, { let count = cx.count(); let motion = move |editor: &mut Editor| { let (view, doc) = current!(editor); let text = doc.text().slice(..); + let annotations = view.text_annotations(doc, None); let behavior = if editor.mode == Mode::Select { Movement::Extend } else { @@ -1257,7 +1266,9 @@ where let selection = doc .selection(view.id) .clone() - .transform(|range| move_fn(text, range, count, behavior)); + .transform(|range| move_fn(text, &annotations, range, count, behavior)); + + drop(annotations); doc.set_selection(view.id, selection); }; cx.editor.apply_motion(motion) @@ -1424,17 +1435,20 @@ fn open_url(cx: &mut Context, url: Url, action: Action) { fn extend_word_impl(cx: &mut Context, extend_fn: F) where - F: Fn(RopeSlice, Range, usize) -> Range, + F: Fn(RopeSlice, &TextAnnotations, Range, usize) -> Range, { let count = cx.count(); let (view, doc) = current!(cx.editor); let text = doc.text().slice(..); + let annotations = view.text_annotations(doc, None); let selection = doc.selection(view.id).clone().transform(|range| { - let word = extend_fn(text, range, count); + let word = extend_fn(text, &annotations, range, count); let pos = word.cursor(text); range.put_cursor(text, pos, true) }); + + drop(annotations); doc.set_selection(view.id, selection); } @@ -1486,135 +1500,70 @@ fn extend_next_sub_word_end(cx: &mut Context) { extend_word_impl(cx, movement::move_next_sub_word_end) } -/// Separate branch to find_char designed only for `` char. -// -// This is necessary because the one document can have different line endings inside. And we -// cannot predict what character to find when is pressed. On the current line it can be `lf` -// but on the next line it can be `crlf`. That's why [`find_char_impl`] cannot be applied here. -fn find_char_line_ending( - cx: &mut Context, - count: usize, - direction: Direction, - inclusive: bool, - extend: bool, -) { - let (view, doc) = current!(cx.editor); - let text = doc.text().slice(..); - - let selection = doc.selection(view.id).clone().transform(|range| { - let cursor = range.cursor(text); - let cursor_line = range.cursor_line(text); - - // Finding the line where we're going to find . Depends mostly on - // `count`, but also takes into account edge cases where we're already at the end - // of a line or the beginning of a line - let find_on_line = match direction { - Direction::Forward => { - let on_edge = line_end_char_index(&text, cursor_line) == cursor; - let line = cursor_line + count - 1 + (on_edge as usize); - if line >= text.len_lines() - 1 { - return range; - } else { - line - } - } - Direction::Backward => { - let on_edge = text.line_to_char(cursor_line) == cursor && !inclusive; - let line = cursor_line as isize - (count as isize - 1 + on_edge as isize); - if line <= 0 { - return range; - } else { - line as usize - } - } - }; - - let pos = match (direction, inclusive) { - (Direction::Forward, true) => line_end_char_index(&text, find_on_line), - (Direction::Forward, false) => line_end_char_index(&text, find_on_line) - 1, - (Direction::Backward, true) => line_end_char_index(&text, find_on_line - 1), - (Direction::Backward, false) => text.line_to_char(find_on_line), - }; - - if extend { - range.put_cursor(text, pos, true) - } else { - Range::point(range.cursor(text)).put_cursor(text, pos, true) - } - }); - doc.set_selection(view.id, selection); -} - fn find_char(cx: &mut Context, direction: Direction, inclusive: bool, extend: bool) { // TODO: count is reset to 1 before next key so we move it into the closure here. // Would be nice to carry over. let count = cx.count(); // need to wait for next key - // TODO: should this be done by grapheme rather than char? For example, - // we can't properly handle the line-ending CRLF case here in terms of char. cx.on_next_key(move |cx, event| { - let ch = match event { - KeyEvent { - code: KeyCode::Enter, - .. - } => { - find_char_line_ending(cx, count, direction, inclusive, extend); - return; + let motion = move |editor: &mut Editor| { + macro_rules! find_char_impl { + ($matcher:expr) => {{ + let search_fn = match direction { + Direction::Forward => find_next_char_impl, + Direction::Backward => find_prev_char_impl, + }; + find_char_impl(editor, &search_fn, inclusive, extend, $matcher, count) + }}; } + match event { + KeyEvent { + code: KeyCode::Enter, + .. + } => find_char_impl!(rope_is_line_ending), - KeyEvent { - code: KeyCode::Tab, .. - } => '\t', + KeyEvent { + code: KeyCode::Tab, .. + } => find_char_impl!('\t'), - KeyEvent { - code: KeyCode::Char(ch), - .. - } => ch, - _ => return, - }; - let motion = move |editor: &mut Editor| { - match direction { - Direction::Forward => { - find_char_impl(editor, &find_next_char_impl, inclusive, extend, ch, count) - } - Direction::Backward => { - find_char_impl(editor, &find_prev_char_impl, inclusive, extend, ch, count) - } - }; + KeyEvent { + code: KeyCode::Char(ch), + .. + } => find_char_impl!(ch), + _ => (), + } }; cx.editor.apply_motion(motion); }) } -// - #[inline] -fn find_char_impl( +fn find_char_impl( editor: &mut Editor, search_fn: &F, inclusive: bool, extend: bool, - char_matcher: M, + matcher: M, count: usize, ) where - F: Fn(RopeSlice, M, usize, usize, bool) -> Option + 'static, + F: Fn(RopeSlice, &TextAnnotations, M, usize, usize, bool) -> Option + 'static, { let (view, doc) = current!(editor); let text = doc.text().slice(..); + let annotations = view.text_annotations(doc, None); let selection = doc.selection(view.id).clone().transform(|range| { - // TODO: use `Range::cursor()` here instead. However, that works in terms of - // graphemes, whereas this function doesn't yet. So we're doing the same logic - // here, but just in terms of chars instead. - let search_start_pos = if range.anchor < range.head { - range.head - 1 - } else { - range.head - }; - - search_fn(text, char_matcher, search_start_pos, count, inclusive).map_or(range, |pos| { + search_fn( + text, + &annotations, + matcher, + range.cursor(text), + count, + inclusive, + ) + .map_or(range, |pos| { if extend { range.put_cursor(text, pos, true) } else { @@ -1622,43 +1571,53 @@ fn find_char_impl( } }) }); + drop(annotations); doc.set_selection(view.id, selection); } fn find_next_char_impl( text: RopeSlice, - ch: char, + annotations: &TextAnnotations, + matcher: impl GraphemeMatcher, pos: usize, n: usize, inclusive: bool, ) -> Option { - let pos = (pos + 1).min(text.len_chars()); if inclusive { - search::find_nth_next(text, ch, pos, n) + search::find_folded_nth_next(text, &annotations.folds, matcher, pos, n) } else { - let n = match text.get_char(pos) { - Some(next_ch) if next_ch == ch => n + 1, + let n = match text + .folded_graphemes_at(&annotations.folds, text.char_to_byte(pos)) + .nth(1) + { + Some(g) if matcher.grapheme_match(g) => n + 1, _ => n, }; - search::find_nth_next(text, ch, pos, n).map(|n| n.saturating_sub(1)) + search::find_folded_nth_next(text, &annotations.folds, matcher, pos, n) + .map(|idx| prev_folded_grapheme_boundary(text, &annotations.folds, idx)) } } fn find_prev_char_impl( text: RopeSlice, - ch: char, + annotations: &TextAnnotations, + matcher: impl GraphemeMatcher, pos: usize, n: usize, inclusive: bool, ) -> Option { if inclusive { - search::find_nth_prev(text, ch, pos, n) + search::find_folded_nth_prev(text, &annotations.folds, matcher, pos, n) } else { - let n = match text.get_char(pos.saturating_sub(1)) { - Some(next_ch) if next_ch == ch => n + 1, + let n = match text + .folded_graphemes_at(&annotations.folds, text.char_to_byte(pos)) + .prev() + { + Some(g) if matcher.grapheme_match(g) => n + 1, _ => n, }; - search::find_nth_prev(text, ch, pos, n).map(|n| (n + 1).min(text.len_chars())) + search::find_folded_nth_prev(text, &annotations.folds, matcher, pos, n) + .map(|idx| next_folded_grapheme_boundary(text, &annotations.folds, idx)) } } @@ -2704,6 +2663,7 @@ fn extend_line_above(cx: &mut Context) { fn extend_line_impl(cx: &mut Context, extend: Extend) { let count = cx.count(); let (view, doc) = current!(cx.editor); + let annotations = view.fold_annotations(doc); let text = doc.text(); let selection = doc.selection(view.id).clone().transform(|range| { @@ -2718,18 +2678,48 @@ fn extend_line_impl(cx: &mut Context, extend: Extend) { // extend to previous/next line if current line is selected let (anchor, head) = if range.from() == start && range.to() == end { match extend { - Extend::Above => (end, text.line_to_char(start_line.saturating_sub(count))), + Extend::Above => ( + end, + text.line_to_char(text.slice(..).nth_prev_folded_line( + &annotations, + start_line, + count, + )), + ), Extend::Below => ( start, - text.line_to_char((end_line + count + 1).min(text.len_lines())), + text.line_to_char({ + let mut idx = + text.slice(..) + .nth_next_folded_line(&annotations, end_line, count); + if idx < text.len_lines() { + idx += 1; + } + idx + }), ), } } else { match extend { - Extend::Above => (end, text.line_to_char(start_line.saturating_sub(count - 1))), + Extend::Above => ( + end, + text.line_to_char(text.slice(..).nth_prev_folded_line( + &annotations, + start_line, + count - 1, + )), + ), Extend::Below => ( start, - text.line_to_char((end_line + count).min(text.len_lines())), + text.line_to_char({ + let mut idx = + text.slice(..) + .nth_next_folded_line(&annotations, end_line, count - 1); + if idx < text.len_lines() { + idx += 1; + } + idx + }), ), } }; @@ -3633,7 +3623,7 @@ async fn make_format_callback( Ok(call) } -#[derive(PartialEq, Eq)] +#[derive(Clone, Copy, PartialEq, Eq)] pub enum Open { Below, Above, @@ -3651,6 +3641,7 @@ fn open(cx: &mut Context, open: Open, comment_continuation: CommentContinuation) let config = cx.editor.config(); let (view, doc) = current!(cx.editor); let loader = cx.editor.syn_loader.load(); + let mut annotations = view.text_annotations(doc, None); let text = doc.text().slice(..); let contents = doc.text(); @@ -3668,6 +3659,33 @@ fn open(cx: &mut Context, open: Open, comment_continuation: CommentContinuation) }; let mut transaction = Transaction::change_by_selection(contents, selection, |range| { + // if open is Below and next line is folded, + // move the range to the next visible line, and open Above + let (range, open) = (open == Open::Below) + .then(|| { + let next_line_is_folded = { + let next_line = text.char_to_line(prev_grapheme_boundary(text, range.to())) + 1; + annotations + .folds + .superest_fold_containing(next_line, |fold| fold.start.line..=fold.end.line) + .is_some() + }; + + next_line_is_folded.then(|| { + move_vertically( + text, + *range, + Direction::Forward, + 1, + Movement::Move, + &TextFormat::default(), + &mut annotations, + ) + }) + }) + .flatten() + .map_or((*range, open), |range| (range, Open::Above)); + // the line number, where the cursor is currently let curr_line_num = text.char_to_line(match open { Open::Below => graphemes::prev_grapheme_boundary(text, range.to()), @@ -3759,6 +3777,7 @@ fn open(cx: &mut Context, open: Open, comment_continuation: CommentContinuation) Some(text.into()), ) }); + drop(annotations); transaction = transaction.with_selection(Selection::new(ranges, selection.primary_index())); @@ -3835,12 +3854,22 @@ fn extend_to_last_line(cx: &mut Context) { fn goto_last_line_impl(cx: &mut Context, movement: Movement) { let (view, doc) = current!(cx.editor); let text = doc.text().slice(..); - let line_idx = if text.line(text.len_lines() - 1).len_chars() == 0 { - // If the last line is blank, don't jump to it. - text.len_lines().saturating_sub(2) + let annotations = &view.fold_annotations(doc); + + let last_visible_line = if let Some(fold) = annotations + .superest_fold_containing(text.len_lines(), |fold| fold.start.line..=fold.end.line) + { + fold.start.line - 1 } else { - text.len_lines() - 1 + text.len_lines().saturating_sub(1) }; + + let line_idx = if text.line(last_visible_line).len_chars() == 0 { + text.prev_folded_line(annotations, last_visible_line) + } else { + last_visible_line + }; + let pos = text.line_to_char(line_idx); let selection = doc .selection(view.id) @@ -4502,7 +4531,13 @@ pub mod insert { delete_by_selection_insert_mode( cx, |text, range| { - let anchor = movement::move_prev_word_start(text, *range, count).from(); + let anchor = movement::move_prev_word_start( + text, + &TextAnnotations::default(), + *range, + count, + ) + .from(); let next = Range::new(anchor, range.cursor(text)); let range = exclude_cursor(text, next, *range); (range.from(), range.to()) @@ -4516,7 +4551,9 @@ pub mod insert { delete_by_selection_insert_mode( cx, |text, range| { - let head = movement::move_next_word_end(text, *range, count).to(); + let head = + movement::move_next_word_end(text, &TextAnnotations::default(), *range, count) + .to(); (range.cursor(text), head) }, Direction::Forward, @@ -4736,6 +4773,7 @@ fn paste_impl( let text = doc.text(); let selection = doc.selection(view.id); + let annotations = view.fold_annotations(doc); let mut offset = 0; let mut ranges = SmallVec::with_capacity(selection.len()); @@ -4747,12 +4785,15 @@ fn paste_impl( // paste linewise after (Paste::After, true) => { let line = range.line_range(text.slice(..)).1; - text.line_to_char((line + 1).min(text.len_lines())) + text.line_to_char(text.slice(..).next_folded_line(&annotations, line)) } // paste insert (Paste::Before, false) => range.from(), // paste append - (Paste::After, false) => range.to(), + (Paste::After, false) => text.slice(..).next_folded_char( + &annotations, + prev_grapheme_boundary(text.slice(..), range.to()), + ), // paste at cursor (Paste::Cursor, _) => range.cursor(text.slice(..)), }; @@ -5717,11 +5758,15 @@ fn split(editor: &mut Editor, action: Action) { let id = doc.id(); let selection = doc.selection(view.id).clone(); let offset = doc.view_offset(view.id); + let container = doc.fold_container(view.id).cloned(); editor.switch(id, action); // match the selection in the previous view let (view, doc) = current!(editor); + if let Some(container) = container { + doc.insert_fold_container(view.id, container); + } doc.set_selection(view.id, selection); // match the view scroll offset (switch doesn't handle this fully // since the selection is only matched after the split) @@ -5901,10 +5946,19 @@ fn goto_ts_object_impl(cx: &mut Context, object: &'static str, direction: Direct if let Some(syntax) = doc.syntax() { let text = doc.text().slice(..); let root = syntax.tree().root_node(); + let annotations = view.text_annotations(doc, None); let selection = doc.selection(view.id).clone().transform(|range| { let new_range = movement::goto_treesitter_object( - text, range, object, direction, &root, syntax, &loader, count, + text, + &annotations, + range, + object, + direction, + &root, + syntax, + &loader, + count, ); if editor.mode == Mode::Select { @@ -5919,6 +5973,7 @@ fn goto_ts_object_impl(cx: &mut Context, object: &'static str, direction: Direct new_range.with_direction(direction) } }); + drop(annotations); push_jump(view, doc); doc.set_selection(view.id, selection); @@ -6825,31 +6880,28 @@ fn jump_to_word(cx: &mut Context, behaviour: Movement) { // Calculate the jump candidates: ranges for any visible words with two or // more characters. let alphabet = &cx.editor.config().jump_label_alphabet; - if alphabet.is_empty() { - return; - } - let jump_label_limit = alphabet.len() * alphabet.len(); let mut words = Vec::with_capacity(jump_label_limit); let (view, doc) = current_ref!(cx.editor); let text = doc.text().slice(..); + let annotations = view.text_annotations(doc, None); // This is not necessarily exact if there is virtual text like soft wrap. // It's ok though because the extra jump labels will not be rendered. let start = text.line_to_char(text.char_to_line(doc.view_offset(view.id).anchor)); - let end = text.line_to_char(view.estimate_last_doc_line(doc) + 1); + let end = text.line_to_char(view.estimate_last_doc_line(&annotations, doc) + 1); let primary_selection = doc.selection(view.id).primary(); let cursor = primary_selection.cursor(text); let mut cursor_fwd = Range::point(cursor); let mut cursor_rev = Range::point(cursor); if text.get_char(cursor).is_some_and(|c| !c.is_whitespace()) { - let cursor_word_end = movement::move_next_word_end(text, cursor_fwd, 1); + let cursor_word_end = movement::move_next_word_end(text, &annotations, cursor_fwd, 1); // single grapheme words need a special case if cursor_word_end.anchor == cursor { cursor_fwd = cursor_word_end; } - let cursor_word_start = movement::move_prev_word_start(text, cursor_rev, 1); + let cursor_word_start = movement::move_prev_word_start(text, &annotations, cursor_rev, 1); if cursor_word_start.anchor == next_grapheme_boundary(text, cursor) { cursor_rev = cursor_word_start; } @@ -6857,7 +6909,7 @@ fn jump_to_word(cx: &mut Context, behaviour: Movement) { 'outer: loop { let mut changed = false; while cursor_fwd.head < end { - cursor_fwd = movement::move_next_word_end(text, cursor_fwd, 1); + cursor_fwd = movement::move_next_word_end(text, &annotations, cursor_fwd, 1); // The cursor is on a word that is atleast two graphemes long and // madeup of word characters. The latter condition is needed because // move_next_word_end simply treats a sequence of characters from @@ -6885,7 +6937,7 @@ fn jump_to_word(cx: &mut Context, behaviour: Movement) { break; } while cursor_rev.head > start { - cursor_rev = movement::move_prev_word_start(text, cursor_rev, 1); + cursor_rev = movement::move_prev_word_start(text, &annotations, cursor_rev, 1); // The cursor is on a word that is atleast two graphemes long and // madeup of word characters. The latter condition is needed because // move_prev_word_start simply treats a sequence of characters from @@ -6916,6 +6968,7 @@ fn jump_to_word(cx: &mut Context, behaviour: Movement) { break; } } + drop(annotations); jump_to_label(cx, words, behaviour) } @@ -6949,3 +7002,129 @@ fn lsp_or_syntax_workspace_symbol_picker(cx: &mut Context) { syntax_workspace_symbol_picker(cx); } } + +fn fold(cx: &mut Context) { + let command: MappableCommand = ":fold --all".parse().unwrap(); + command.execute(cx); +} + +fn unfold(cx: &mut Context) { + let command: MappableCommand = ":unfold --all".parse().unwrap(); + command.execute(cx); +} + +fn toggle_fold(cx: &mut Context) { + use graphemes::ensure_grapheme_boundary_prev; + use text_folding::{Fold, FoldObject}; + + let (view, doc) = current!(cx.editor); + let text = doc.text().slice(..); + let cursor = doc.selection(view.id).primary().cursor(text); + let loader = cx.editor.syn_loader.load(); + + let Some(syntax) = doc.syntax() else { + cx.editor + .set_error("Syntax is unavailable in the current buffer."); + return; + }; + + let Some(textobject_query) = loader.textobject_query(syntax.root_language()) else { + cx.editor.set_error("Failed to compile text object query."); + return; + }; + + let textobjects = &["class.around", "function.around", "comment.around"]; + let root_node = syntax.tree().root_node(); + + // search for a textobject at the cursor + let Some((capture_name, node_range)) = textobject_query + .capture_nodes_all(textobjects, &root_node, text) + .map(|(cap, node)| (cap.name(textobject_query.query()), node.byte_range())) + .filter(|(_, range)| range.contains(&text.char_to_byte(cursor))) + .min_by_key(|(_, range)| range.len()) + .map(|(cap, range)| { + (cap, { + let start = text.byte_to_char(range.start); + let end = ensure_grapheme_boundary_prev(text, text.byte_to_char(range.end - 1)); + start..=end + }) + }) + else { + cx.editor + .set_status("There is no text object at the cursor."); + return; + }; + + let object = { + let textobject = match capture_name { + "class.around" => "class", + "function.around" => "function", + "comment.around" => "comment", + other => unreachable!("Unexpected textobject {other}"), + }; + FoldObject::TextObject(textobject) + }; + + let fold = doc.fold_container(view.id).and_then(|container| { + container.find(&object, &node_range, |fold| fold.header()..=fold.end.target) + }); + if let Some(fold) = fold { + doc.remove_folds(view, vec![fold.start_idx()]); + return; + } + + let header = *node_range.start(); + let target = { + match capture_name { + "class.around" | "function.around" => { + let capture = match capture_name { + "class.around" => "class.inside", + "function.around" => "function.inside", + _ => unreachable!(), + }; + let byte_range = { + let start = text.char_to_byte(*node_range.start()); + let end = text.char_to_byte(next_grapheme_boundary(text, *node_range.end())); + start..end + }; + let node = syntax + .descendant_for_byte_range(byte_range.start as u32, byte_range.end as u32) + .expect("The range must belong to the captured node."); + let Some(target) = || -> Option<_> { + textobject_query + .capture_nodes(capture, &node, text)? + .next() + .map(|cap_node| { + let start = text.byte_to_char(cap_node.start_byte()); + let end = ensure_grapheme_boundary_prev( + text, + text.byte_to_char(cap_node.end_byte() - 1), + ); + start..=end + }) + }() else { + return; + }; + target + } + "comment.around" => { + let start_line = text.char_to_line(*node_range.start()); + let end_line = text.char_to_line(*node_range.end()); + if start_line >= end_line { + cx.editor.set_status("One-line comment does not fold."); + return; + } + let start = text.line_to_char(start_line + 1) + + text + .line(start_line + 1) + .first_non_whitespace_char() + .unwrap_or(0); + start..=*node_range.end() + } + other => unreachable!("Unexpected textobject {other}"), + } + }; + + let new_fold = Fold::new_points(text, object, header, &target); + doc.add_folds(view, vec![new_fold]); +} diff --git a/helix-term/src/commands/lsp.rs b/helix-term/src/commands/lsp.rs index 0494db3e7bb0..1bc3287ea0db 100644 --- a/helix-term/src/commands/lsp.rs +++ b/helix-term/src/commands/lsp.rs @@ -15,7 +15,7 @@ use super::{align_view, push_jump, Align, Context, Editor}; use helix_core::{ diagnostic::DiagnosticProvider, syntax::config::LanguageServerFeature, - text_annotations::InlineAnnotation, Selection, Uri, + text_annotations::InlineAnnotation, text_folding::ropex::RopeSliceFoldExt, Selection, Uri, }; use helix_stdx::path; use helix_view::{ @@ -1298,7 +1298,7 @@ fn compute_inlay_hints_for_view( .next()?; let doc_text = doc.text(); - let len_lines = doc_text.len_lines(); + let annotations = &view.fold_annotations(doc); // Compute ~3 times the current view height of inlay hints, that way some scrolling // will not show half the view with hints and half without while still being faster @@ -1308,9 +1308,11 @@ fn compute_inlay_hints_for_view( let first_visible_line = doc_text.char_to_line(doc.view_offset(view_id).anchor.min(doc_text.len_chars())); let first_line = first_visible_line.saturating_sub(view_height); - let last_line = first_visible_line - .saturating_add(view_height.saturating_mul(2)) - .min(len_lines); + let last_line = doc_text.slice(..).nth_next_folded_line( + annotations, + first_visible_line, + view_height.saturating_mul(2), + ); let new_doc_inlay_hints_id = DocumentInlayHintsId { first_line, diff --git a/helix-term/src/commands/typed.rs b/helix-term/src/commands/typed.rs index 4831b9382f7c..80aa4be9c7b4 100644 --- a/helix-term/src/commands/typed.rs +++ b/helix-term/src/commands/typed.rs @@ -10,6 +10,8 @@ use helix_core::command_line::{Args, Flag, Signature, Token, TokenKind}; use helix_core::fuzzy::fuzzy_match; use helix_core::indent::MAX_INDENT; use helix_core::line_ending; +use helix_core::syntax::Loader; +use helix_core::text_folding; use helix_stdx::path::home_dir; use helix_view::document::{read_to_string, DEFAULT_LANGUAGE_NAME}; use helix_view::editor::{CloseError, ConfigEvent}; @@ -2636,6 +2638,504 @@ fn echo(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> anyhow: Ok(()) } +/// The signature is public because it is used +/// for default folding when a file is opened. +pub const FOLD_SIGNATURE: Signature = Signature { + positionals: (0, None), + flags: &[ + Flag { + name: "selection", + alias: Some('s'), + doc: "Fold selection text.", + ..Flag::DEFAULT + }, + Flag { + name: "document", + alias: Some('d'), + doc: "Fold textobjects within an entire document.", + ..Flag::DEFAULT + }, + Flag { + name: "all", + alias: Some('a'), + doc: "Fold all textobjects, excluding specified ones.", + ..Flag::DEFAULT + }, + ], + ..Signature::DEFAULT +}; + +fn fold(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> anyhow::Result<()> { + if event != PromptEvent::Validate { + return Ok(()); + } + + let (view, doc) = current!(cx.editor); + + if args.has_flag("selection") { + fold_selection(doc, view, args) + } else { + let loader = cx.editor.syn_loader.load(); + fold_textobjects(doc, view, &loader, args) + } +} + +fn fold_selection(doc: &mut Document, view: &View, args: Args) -> anyhow::Result<()> { + use text_folding::{Fold, FoldObject}; + + // additional validation + let invalid = args.has_flag("document") || args.has_flag("all") || args.first().is_some(); + if invalid { + return Err(anyhow!( + "Flag `document`, flag `all`, and positional arguments are unavailable \ + with the flag `selection`." + )); + } + + let text = doc.text().slice(..); + let range = doc.selection(view.id).primary(); + let (start, end) = range.line_range(text); + + if start == end { + return Err(anyhow!( + "Nothing to fold. \ + The line range of the selection must include at least two lines." + )); + } + + if line_ending::get_line_ending(&text.line(end)).is_none() { + return Err(anyhow!( + "Nothing to fold. \ + The end line of the selection must have a line ending." + )); + } + + let object = FoldObject::Selection; + let header = text.line_to_char(start); + let target = { + let start = text.line_to_char(start + 1) + + text + .line(start + 1) + .first_non_whitespace_char() + .unwrap_or(0); + let end = text + .line(end) + .last_non_whitespace_char() + .map_or(line_end_char_index(&text, end), |char| { + text.line_to_char(end) + char + }); + start..=end + }; + + let points = vec![Fold::new_points(text, object, header, &target)]; + doc.replace_folds(view, points); + + Ok(()) +} + +/// The function is public because it is used +/// for default folding when a file is opened. +pub fn fold_textobjects( + doc: &mut Document, + view: &View, + loader: &Loader, + args: Args, +) -> anyhow::Result<()> { + use std::cmp::{max, min}; + use std::ops; + + use graphemes::{ensure_grapheme_boundary_prev, prev_grapheme_boundary}; + use text_folding::{Fold, FoldObject}; + + let Some(syntax) = doc.syntax() else { + return Err(anyhow!("Syntax is unavailable in the current buffer.")); + }; + + let Some(textobject_query) = loader.textobject_query(syntax.root_language()) else { + return Err(anyhow!("Failed to compile text object query.")); + }; + + let text = doc.text().slice(..); + let root_node = syntax.tree().root_node(); + let range = doc.selection(view.id).primary(); + + let textobjects: Vec<_> = ["class", "function", "comment"] + .into_iter() + .filter(|textobject| args.contains(textobject) ^ args.has_flag("all")) + .map(|textobject| match textobject { + "class" => "class.around", + "function" => "function.around", + "comment" => "comment.around", + other => unreachable!("Unexpected textobject {other}."), + }) + .collect(); + if textobjects.is_empty() { + return Err(anyhow!("The list of text objects is empty.")); + } + + // the range is used to determine search boundaries + let search_range = if args.has_flag("document") { + 0..text.len_bytes() + } else { + let (start, end) = range.into_byte_range(text); + start..end + }; + + // the range is used to determine nesting + let nesting_range = if args.has_flag("document") { + 0..text.len_bytes() + } else { + let start = text.char_to_byte(range.from()); + let end = if range.is_empty() { + text.char_to_byte(range.from()) + } else { + text.char_to_byte(prev_grapheme_boundary(text, range.to())) + }; + + let join = |r1: &ops::Range<_>, r2: &ops::Range<_>| { + let start = min(r1.start, r2.start); + let end = max(r1.end, r2.end); + start..end + }; + + // the range of the captured node contains the start byte + let top = textobject_query + .capture_nodes_all(&textobjects, &root_node, text) + .map(|(_, cap_node)| cap_node.byte_range()) + .filter(|range| range.contains(&start)) + .min_by_key(|range| range.len()); + + // the range of the captured node contains the end byte + let bottom = textobject_query + .capture_nodes_all(&textobjects, &root_node, text) + .map(|(_, cap_node)| cap_node.byte_range()) + .filter(|range| range.contains(&end)) + .min_by_key(|range| range.len()); + + match (top, bottom) { + (None, None) => 0..text.len_bytes(), + (None, Some(range)) | (Some(range), None) => join(&range, &search_range), + (Some(top), Some(bottom)) => { + let joined = join(&top, &bottom); + if joined == top { + bottom + } else if joined == bottom { + top + } else { + joined + } + } + } + }; + + let fold_points: Vec<_> = textobject_query + .capture_nodes_all(&textobjects, &root_node, text) + .filter_map(|(cap, cap_node)| { + let range = cap_node.byte_range(); + + // the captured node's range overlaps with the search range + let overlapped = { + let start = max(range.start, search_range.start); + let end = min(range.end, search_range.end); + !(start..end).is_empty() + }; + + // the captured node's range is nested within the nesting range + let nested = { + let start = max(range.start, nesting_range.start); + let end = min(range.end, nesting_range.end); + (start..end) == range + }; + + (overlapped && nested).then_some((cap, range)) + }) + .filter_map(|(cap, range)| { + let capture_name = cap.name(textobject_query.query()); + match capture_name { + "class.around" | "function.around" => { + let (capture, textobject) = match capture_name { + "class.around" => ("class.inside", "class"), + "function.around" => ("function.inside", "function"), + _ => unreachable!(), + }; + let node = syntax + .descendant_for_byte_range(range.start as u32, range.end as u32) + .expect("The range must belong to the captured node."); + textobject_query + .capture_nodes(capture, &node, text)? + .next() + .map(|cap_node| { + let header = text.byte_to_char(range.start); + let target = { + let start = text.byte_to_char(cap_node.start_byte()); + let end = ensure_grapheme_boundary_prev( + text, + text.byte_to_char(cap_node.end_byte() - 1), + ); + start..=end + }; + (FoldObject::TextObject(textobject), header, target) + }) + } + "comment.around" => { + let start_line = text.byte_to_line(range.start); + let end_line = text.byte_to_line(range.end - 1); + (start_line < end_line).then(|| { + let object = FoldObject::TextObject("comment"); + let header = text.byte_to_char(range.start); + let target = { + let start = text.line_to_char(start_line + 1) + + text + .line(start_line + 1) + .first_non_whitespace_char() + .unwrap_or(0); + let end = ensure_grapheme_boundary_prev( + text, + text.byte_to_char(range.end - 1), + ); + start..=end + }; + (object, header, target) + }) + } + other => unreachable!("Unexpected capture name: {other}."), + } + }) + // the last line of target must have line ending + .filter(|(_, _, target)| { + let end_line = text.line(text.char_to_line(*target.end())); + line_ending::get_line_ending(&end_line).is_some() + }) + // create fold points + .map(|(object, header, target)| Fold::new_points(text, object, header, &target)) + // filter out existing folds + .filter(|(sfp, efp)| { + let Some(container) = doc.fold_container(view.id) else { + return true; + }; + let fold = Fold::new(sfp, efp); + let target = |fold: Fold| fold.start.target..=fold.end.target; + container + .find(fold.object(), &target(fold), target) + .is_none() + }) + .collect(); + if fold_points.is_empty() { + return Err(anyhow!("Nothing to fold.")); + } + + doc.add_folds(view, fold_points); + + Ok(()) +} + +fn unfold(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> anyhow::Result<()> { + if event != PromptEvent::Validate { + return Ok(()); + } + + let (view, doc) = current!(cx.editor); + + if args.has_flag("selection") { + unfold_selection(doc, view, args) + } else { + let loader = cx.editor.syn_loader.load(); + unfold_textobjects(doc, view, &loader, args) + } +} + +fn unfold_selection(doc: &mut Document, view: &View, args: Args) -> anyhow::Result<()> { + use std::cmp::{max, min}; + + use graphemes::prev_grapheme_boundary; + use text_folding::FoldObject; + + // additional validation + let invalid = args.has_flag("all") || args.first().is_some(); + if invalid { + return Err(anyhow!( + "Flag `all` and positional arguments are unavailable \ + with the flag `selection`." + )); + } + + let text = doc.text().slice(..); + let Some(container) = doc.fold_container(view.id) else { + return Err(anyhow!("Fold container is empty.")); + }; + let range = doc.selection(view.id).primary(); + + // the range is used to determine search boundaries + let search_range = if args.has_flag("document") { + 0..=prev_grapheme_boundary(text, text.len_chars()) + } else { + let start = range.from(); + let end = if range.from() == range.to() { + range.to() + } else { + prev_grapheme_boundary(text, range.to()) + }; + start..=end + }; + + let start_indices: Vec<_> = container + .start_points() + .iter() + .enumerate() + .filter(|(_, sfp)| matches!(sfp.object, FoldObject::Selection)) + .filter(|(_, sfp)| sfp.is_superest() || args.has_flag("recursive")) + .filter(|(_, sfp)| { + let fold = sfp.fold(container); + let range = fold.header()..=fold.end.target; + + let start = max(*range.start(), *search_range.start()); + let end = min(*range.end(), *search_range.end()); + !(start..=end).is_empty() + }) + .map(|(idx, _)| idx) + .collect(); + if start_indices.is_empty() { + return Err(anyhow!("Nothing to unfold.")); + } + + doc.remove_folds(view, start_indices); + + Ok(()) +} + +fn unfold_textobjects( + doc: &mut Document, + view: &View, + loader: &Loader, + args: Args, +) -> anyhow::Result<()> { + use std::cmp::{max, min}; + use std::ops; + + use graphemes::prev_grapheme_boundary; + use text_folding::FoldObject; + + let Some(syntax) = doc.syntax() else { + return Err(anyhow!("Syntax is unavailable in the current buffer.")); + }; + + let Some(textobject_query) = loader.textobject_query(syntax.root_language()) else { + return Err(anyhow!("Failed to compile text object query.")); + }; + + let text = doc.text().slice(..); + let root_node = syntax.tree().root_node(); + let Some(container) = doc.fold_container(view.id) else { + return Err(anyhow!("Fold container is empty.")); + }; + let range = doc.selection(view.id).primary(); + let (start, end) = range.into_byte_range(text); + + let textobjects: Vec<_> = ["class", "function", "comment"] + .into_iter() + .filter(|textobject| args.contains(textobject) ^ args.has_flag("all")) + .map(|textobject| match textobject { + "class" => "class.around", + "function" => "function.around", + "comment" => "comment.around", + _ => unreachable!(), + }) + .collect(); + if textobjects.is_empty() { + return Err(anyhow!("The list of textobjects is empty.")); + } + + // the range is used to determine search boundaries + let search_range = if args.has_flag("document") { + 0..text.len_bytes() + } else { + let (start, end) = range.into_byte_range(text); + start..end + }; + + // the range is used to determine nesting + let nesting_range = if args.has_flag("document") { + 0..text.len_bytes() + } else { + let join = |r1: &ops::Range<_>, r2: &ops::Range<_>| { + let start = min(r1.start, r2.start); + let end = max(r1.end, r2.end); + start..end + }; + + // the range of the captured node contains the start byte + let top = textobject_query + .capture_nodes_all(&textobjects, &root_node, text) + .map(|(_, cap_node)| cap_node.byte_range()) + .filter(|range| range.contains(&start)) + .min_by_key(|range| range.len()); + + // the range of the captured node contains the end byte + let bottom = textobject_query + .capture_nodes_all(&textobjects, &root_node, text) + .map(|(_, cap_node)| cap_node.byte_range()) + .filter(|range| range.contains(&end)) + .min_by_key(|range| range.len()); + + match (top, bottom) { + (None, None) => 0..text.len_bytes(), + (None, Some(range)) | (Some(range), None) => join(&range, &search_range), + (Some(top), Some(bottom)) => join(&top, &bottom), + } + }; + + // convert the byte range into the char range inclusive + let convert = |range: ops::Range<_>| { + let start = text.byte_to_char(range.start); + let end = prev_grapheme_boundary(text, text.byte_to_char(range.end)); + start..=end + }; + + let search_range = convert(search_range); + let nesting_range = convert(nesting_range); + + let start_indices: Vec<_> = container + .start_points() + .iter() + .enumerate() + .filter(|(_, sfp)| { + matches!(sfp.object, + FoldObject::TextObject(textobject) + if args.contains(textobject) ^ args.has_flag("all") + ) + }) + .filter(|(_, sfp)| sfp.is_superest() || args.has_flag("recursive")) + .filter(|(_, sfp)| { + let fold = sfp.fold(container); + let range = fold.header()..=fold.end.target; + + // the fold's range overlaps with the search range + let overlapped = { + let start = max(*range.start(), *search_range.start()); + let end = min(*range.end(), *search_range.end()); + !(start..=end).is_empty() + }; + + // the fold's range is nested within the nesting range + let nested = { + let start = max(*range.start(), *nesting_range.start()); + let end = min(*range.end(), *nesting_range.end()); + (start..=end) == range + }; + + overlapped && nested + }) + .map(|(idx, _)| idx) + .collect(); + if start_indices.is_empty() { + return Err(anyhow!("Nothing to unfold.")); + } + + doc.remove_folds(view, start_indices); + + Ok(()) +} + fn noop(_cx: &mut compositor::Context, _args: Args, _event: PromptEvent) -> anyhow::Result<()> { Ok(()) } @@ -3655,6 +4155,51 @@ pub const TYPABLE_COMMAND_LIST: &[TypableCommand] = &[ ..Signature::DEFAULT }, }, + TypableCommand { + name: "fold", + aliases: &[], + doc: "Fold text.", + fun: fold, + completer: CommandCompleter::all(completers::foldable_textobjects), + signature: FOLD_SIGNATURE, + }, + TypableCommand { + name: "unfold", + aliases: &[], + doc: "Unfold text.", + fun: unfold, + completer: CommandCompleter::all(completers::foldable_textobjects), + signature: Signature { + positionals: (0, Some(3)), + flags: &[ + Flag { + name: "selection", + alias: Some('s'), + doc: "Unfold folds that were folded with the flag `selection`.", + ..Flag::DEFAULT + }, + Flag { + name: "document", + alias: Some('d'), + doc: "Unfold folds within an entire document.", + ..Flag::DEFAULT + }, + Flag { + name: "all", + alias: Some('a'), + doc: "Unfold all textobjects, excluding specified ones.", + ..Flag::DEFAULT + }, + Flag { + name: "recursive", + alias: Some('r'), + doc: "Unfold both superest and nested folds.", + ..Flag::DEFAULT + }, + ], + ..Signature::DEFAULT + }, + }, TypableCommand { name: "noop", aliases: &[], diff --git a/helix-term/src/handlers/completion/word.rs b/helix-term/src/handlers/completion/word.rs index aa204bf8f73d..739dc179adc6 100644 --- a/helix-term/src/handlers/completion/word.rs +++ b/helix-term/src/handlers/completion/word.rs @@ -1,7 +1,8 @@ use std::{borrow::Cow, sync::Arc}; use helix_core::{ - self as core, chars::char_is_word, completion::CompletionProvider, movement, Transaction, + self as core, chars::char_is_word, completion::CompletionProvider, movement, + text_annotations::TextAnnotations, Transaction, }; use helix_event::TaskHandle; use helix_stdx::rope::RopeSliceExt as _; @@ -38,7 +39,12 @@ pub(super) fn completion( let selection = doc.selection(view.id).clone(); let pos = selection.primary().cursor(text); - let cursor = movement::move_prev_word_start(text, core::Range::point(pos), 1); + let cursor = movement::move_prev_word_start( + text, + &TextAnnotations::default(), + core::Range::point(pos), + 1, + ); if cursor.head == pos { return None; } diff --git a/helix-term/src/keymap/default.rs b/helix-term/src/keymap/default.rs index 5bbbd3f40429..f3ba7ebca487 100644 --- a/helix-term/src/keymap/default.rs +++ b/helix-term/src/keymap/default.rs @@ -305,6 +305,10 @@ pub fn default() -> HashMap { "C-u" | "backspace" => page_cursor_half_up, "C-d" | "space" => page_cursor_half_down, + "f" => fold, + "F" => unfold, + "A-f" => toggle_fold, + "/" => search, "?" => rsearch, "n" => search_next, @@ -322,6 +326,10 @@ pub fn default() -> HashMap { "C-u" | "backspace" => page_cursor_half_up, "C-d" | "space" => page_cursor_half_down, + "f" => fold, + "F" => unfold, + "A-f" => toggle_fold, + "/" => search, "?" => rsearch, "n" => search_next, diff --git a/helix-term/src/ui/editor.rs b/helix-term/src/ui/editor.rs index b25af107d796..8dd30ae51dc9 100644 --- a/helix-term/src/ui/editor.rs +++ b/helix-term/src/ui/editor.rs @@ -8,7 +8,9 @@ use crate::{ ui::{ document::{render_document, LinePos, TextRenderer}, statusline, - text_decorations::{self, Decoration, DecorationManager, InlineDiagnostics}, + text_decorations::{ + self, Decoration, DecorationManager, FoldDecoration, InlineDiagnostics, + }, Completion, ProgressSpinners, }, }; @@ -19,6 +21,7 @@ use helix_core::{ movement::Direction, syntax::{self, OverlayHighlights}, text_annotations::TextAnnotations, + text_folding::RopeSliceFoldExt, unicode::width::UnicodeWidthStr, visual_offset_from_block, Change, Position, Range, Selection, Transaction, }; @@ -98,6 +101,8 @@ impl EditorView { decorations.add_decoration(Self::cursorline(doc, view, theme)); } + decorations.add_decoration(FoldDecoration::new(&text_annotations, theme)); + if is_focused && config.cursorcolumn { Self::highlight_cursorcolumn(doc, view, surface, theme, inner, &text_annotations); } @@ -116,8 +121,13 @@ impl EditorView { decorations.add_decoration(line_decoration); } - let syntax_highlighter = - Self::doc_syntax_highlighter(doc, view_offset.anchor, inner.height, &loader); + let syntax_highlighter = Self::doc_syntax_highlighter( + doc, + &text_annotations, + view_offset.anchor, + inner.height, + &loader, + ); let mut overlays = Vec::new(); overlays.push(Self::overlay_syntax_highlights( @@ -132,9 +142,14 @@ impl EditorView { .and_then(|config| config.rainbow_brackets) .unwrap_or(config.rainbow_brackets) { - if let Some(overlay) = - Self::doc_rainbow_highlights(doc, view_offset.anchor, inner.height, theme, &loader) - { + if let Some(overlay) = Self::doc_rainbow_highlights( + doc, + &text_annotations, + view_offset.anchor, + inner.height, + theme, + &loader, + ) { overlays.push(overlay); } } @@ -269,13 +284,16 @@ impl EditorView { fn viewport_byte_range( text: helix_core::RopeSlice, + annotations: &TextAnnotations, row: usize, height: u16, ) -> std::ops::Range { // Calculate viewport byte ranges: // Saturating subs to make it inclusive zero indexing. let last_line = text.len_lines().saturating_sub(1); - let last_visible_line = (row + height as usize).saturating_sub(1).min(last_line); + let last_visible_line = text + .nth_next_folded_line(&annotations.folds, row, (height as usize).saturating_sub(1)) + .min(last_line); let start = text.line_to_byte(row.min(last_line)); let end = text.line_to_byte(last_visible_line + 1); @@ -287,6 +305,7 @@ impl EditorView { /// directly to enable rendering syntax highlighted docs anywhere (eg. picker preview) pub fn doc_syntax_highlighter<'editor>( doc: &'editor Document, + annotations: &TextAnnotations, anchor: usize, height: u16, loader: &'editor syntax::Loader, @@ -294,7 +313,7 @@ impl EditorView { let syntax = doc.syntax()?; let text = doc.text().slice(..); let row = text.char_to_line(anchor.min(text.len_chars())); - let range = Self::viewport_byte_range(text, row, height); + let range = Self::viewport_byte_range(text, annotations, row, height); let range = range.start as u32..range.end as u32; let highlighter = syntax.highlighter(text, loader, range); @@ -310,7 +329,7 @@ impl EditorView { let text = doc.text().slice(..); let row = text.char_to_line(anchor.min(text.len_chars())); - let mut range = Self::viewport_byte_range(text, row, height); + let mut range = Self::viewport_byte_range(text, text_annotations, row, height); range = text.byte_to_char(range.start)..text.byte_to_char(range.end); text_annotations.collect_overlay_highlights(range) @@ -318,6 +337,7 @@ impl EditorView { pub fn doc_rainbow_highlights( doc: &Document, + annotations: &TextAnnotations, anchor: usize, height: u16, theme: &Theme, @@ -326,7 +346,7 @@ impl EditorView { let syntax = doc.syntax()?; let text = doc.text().slice(..); let row = text.char_to_line(anchor.min(text.len_chars())); - let visible_range = Self::viewport_byte_range(text, row, height); + let visible_range = Self::viewport_byte_range(text, annotations, row, height); let start = syntax::child_for_byte_range( &syntax.tree().root_node(), visible_range.start as u32..visible_range.end as u32, diff --git a/helix-term/src/ui/mod.rs b/helix-term/src/ui/mod.rs index 58b6fc008ac6..4ea54cc291a3 100644 --- a/helix-term/src/ui/mod.rs +++ b/helix-term/src/ui/mod.rs @@ -269,13 +269,23 @@ pub fn file_picker(editor: &Editor, root: PathBuf) -> FilePicker { }, )]; let picker = Picker::new(columns, 0, [], data, move |cx, path: &PathBuf, action| { - if let Err(e) = cx.editor.open(path, action) { - let err = if let Some(err) = e.source() { - format!("{}", err) - } else { - format!("unable to open \"{}\"", path.display()) - }; - cx.editor.set_error(err); + let path = helix_stdx::path::canonicalize(path); + let old_id = cx.editor.document_id_by_path(&path); + + match cx.editor.open(&path, action) { + Ok(doc_id) => { + if old_id.map_or(true, |id| id != doc_id) { + default_folding(cx.editor); + } + } + Err(e) => { + let err = if let Some(err) = e.source() { + format!("{}", err) + } else { + format!("unable to open \"{}\"", path.display()) + }; + cx.editor.set_error(err); + } } }) .with_preview(|_editor, path| Some((path.as_path().into(), None))); @@ -339,13 +349,25 @@ pub fn file_explorer(root: PathBuf, editor: &Editor) -> Result { + if old_id.map_or(true, |id| id != doc_id) { + default_folding(cx.editor); + } + } + Err(e) => { + let err = if let Some(err) = e.source() { + format!("{}", err) + } else { + format!("unable to open \"{}\"", path.display()) + }; + cx.editor.set_error(err); + } + } } }, ) @@ -402,6 +424,24 @@ fn directory_content(root: &Path, editor: &Editor) -> Result Option { let mut entries = path.read_dir().ok()?; let entry = entries.next()?.ok()?; @@ -783,6 +823,14 @@ pub mod completers { completions } + + pub fn foldable_textobjects(_editor: &Editor, input: &str) -> Vec { + let textobjects = ["class", "function", "comment"]; + fuzzy_match(input, textobjects.iter(), false) + .into_iter() + .map(|(name, _)| ((0..), (*name).into())) + .collect() + } } #[cfg(test)] diff --git a/helix-term/src/ui/picker.rs b/helix-term/src/ui/picker.rs index ee4a163d3608..dc3a3cc2d0cb 100644 --- a/helix-term/src/ui/picker.rs +++ b/helix-term/src/ui/picker.rs @@ -960,8 +960,13 @@ impl Picker { let loader = cx.editor.syn_loader.load(); - let syntax_highlighter = - EditorView::doc_syntax_highlighter(doc, offset.anchor, area.height, &loader); + let syntax_highlighter = EditorView::doc_syntax_highlighter( + doc, + &TextAnnotations::default(), + offset.anchor, + area.height, + &loader, + ); let mut overlay_highlights = Vec::new(); EditorView::doc_diagnostics_highlights_into( diff --git a/helix-term/src/ui/text_decorations.rs b/helix-term/src/ui/text_decorations.rs index 931ea431178c..31a245a52892 100644 --- a/helix-term/src/ui/text_decorations.rs +++ b/helix-term/src/ui/text_decorations.rs @@ -1,8 +1,11 @@ use std::cmp::Ordering; use helix_core::doc_formatter::FormattedGrapheme; +use helix_core::text_annotations::TextAnnotations; +use helix_core::text_folding::FoldAnnotations; use helix_core::Position; use helix_view::editor::CursorCache; +use helix_view::theme::{Style, Theme}; use crate::ui::document::{LinePos, TextRenderer}; @@ -173,3 +176,67 @@ impl Decoration for Cursor<'_> { usize::MAX } } + +pub(crate) struct FoldDecoration<'a> { + annotations: FoldAnnotations<'a>, + style: Style, +} + +impl<'a> FoldDecoration<'a> { + pub(crate) fn new(annotations: &'a TextAnnotations<'a>, theme: &Theme) -> Self { + Self { + annotations: annotations.folds.clone(), + style: theme.get("ui.virtual.fold-decoration"), + } + } +} + +impl<'a> Decoration for FoldDecoration<'a> { + fn render_virt_lines( + &mut self, + renderer: &mut TextRenderer, + pos: LinePos, + virt_off: Position, + ) -> Position { + use helix_core::Tendril; + use std::fmt::Write; + + let Some(fold) = self + .annotations + .consume_next(pos.doc_line, |fold| fold.start.line - 1) + else { + return Position::new(0, 0); + }; + + let draw_col = virt_off.col as u16 + 1; + let width = renderer.viewport.width; + let text = { + let mut text = Tendril::new(); + let len_lines = fold.end.line - fold.start.line + 1; + text.write_fmt(format_args!( + "...+{len_lines} {object} {measure}", + object = fold.object(), + measure = if len_lines > 1 { "lines" } else { "line" }, + )) + .unwrap(); + text + }; + + renderer.set_string_truncated( + renderer.viewport.x + draw_col, + pos.visual_line, + &text, + width.saturating_sub(draw_col) as usize, + |_| self.style, + true, + false, + ); + + Position::new(1, 0) + } + + fn reset_pos(&mut self, pos: usize) -> usize { + self.annotations.reset_pos(pos, |fold| fold.start.char - 1); + usize::MAX + } +} diff --git a/helix-term/tests/test/commands.rs b/helix-term/tests/test/commands.rs index 90ff4cf0cdc4..da32be1b369a 100644 --- a/helix-term/tests/test/commands.rs +++ b/helix-term/tests/test/commands.rs @@ -6,6 +6,7 @@ mod insert; mod movement; mod reverse_selection_contents; mod rotate_selection_contents; +mod text_folding; mod write; #[tokio::test(flavor = "multi_thread")] diff --git a/helix-term/tests/test/commands/text_folding.rs b/helix-term/tests/test/commands/text_folding.rs new file mode 100644 index 000000000000..d5b261c2e080 --- /dev/null +++ b/helix-term/tests/test/commands/text_folding.rs @@ -0,0 +1,1714 @@ +use super::*; + +use std::cell::Cell; + +use helix_core::doc_formatter::{DocumentFormatter, TextFormat}; +use helix_core::graphemes::prev_grapheme_boundary; +use helix_core::{coords_at_pos, Position, Range}; +use helix_core::{Rope, RopeSlice}; + +use helix_view::current_ref; + +const RUST_CODE: &str = "tests/test/commands/text_folding/rust-code.rs"; +const PYTHON_CODE: &str = "tests/test/commands/text_folding/python-code.py"; + +const FOLDED_RUST_CODE: &str = "tests/test/commands/text_folding/folded-rust-code"; +const FOLDED_PYTHON_CODE: &str = "tests/test/commands/text_folding/folded-python-code"; + +fn fold_text(app: &Application) -> Rope { + use helix_core::graphemes::Grapheme; + use std::fmt::Write; + + let (view, doc) = current_ref!(&app.editor); + let text = doc.text().slice(..); + let text_format = &TextFormat::default(); + let annotations = &view.text_annotations(doc, None); + + let formatter = DocumentFormatter::new_at_prev_checkpoint(text, text_format, annotations, 0); + + let mut folded_text = String::new(); + for g in formatter { + match g.raw { + Grapheme::Newline => write!(folded_text, "\n").unwrap(), + other => write!(folded_text, "{other}").unwrap(), + } + } + // remove EOF + folded_text.remove(folded_text.len() - 1); + + Rope::from(folded_text) +} + +// NOTE: positions are one-based indexing +// Returns (from, to) +fn positions_from_range(text: RopeSlice, range: Range) -> (Position, Position) { + let Position { row, col } = coords_at_pos(text, range.from()); + let from = Position { + row: row + 1, + col: col + 1, + }; + + let Position { row, col } = coords_at_pos(text, prev_grapheme_boundary(text, range.to())); + let to = Position { + row: row + 1, + col: col + 1, + }; + + (from, to) +} + +// NOTE: position is one-based indexing +fn position_from_char(text: RopeSlice, char: usize) -> Position { + let Position { row, col } = coords_at_pos(text, char); + Position { + row: row + 1, + col: col + 1, + } +} + +// INFO: to update the folded text, set the environment variable HELIX_UPDATE_FOLDED_RUST_CODE +#[tokio::test(flavor = "multi_thread")] +async fn fold_rust_code() -> anyhow::Result<()> { + use std::fs::File; + + let app = &mut AppBuilder::new() + .with_file(RUST_CODE, None) + .with_lang_loader(helpers::test_syntax_loader(None)) + .build() + .unwrap(); + + test_key_sequence( + app, + Some(":fold --all --document"), + Some(&|app| { + let folded_rust_code = fold_text(app); + match std::env::var("HELIX_UPDATE_FOLDED_RUST_CODE") { + Ok(_) => { + folded_rust_code + .write_to(File::create(FOLDED_RUST_CODE).unwrap()) + .unwrap(); + } + Err(_) => { + let expected = + Rope::from_reader(File::open(FOLDED_RUST_CODE).unwrap()).unwrap(); + assert_eq!(folded_rust_code, expected); + } + } + }), + false, + ) + .await +} + +// INFO: to update the folded text, set the environment variable HELIX_UPDATE_FOLDED_PYTHON_CODE +#[tokio::test(flavor = "multi_thread")] +async fn fold_python_code() -> anyhow::Result<()> { + use std::fs::File; + + let app = &mut AppBuilder::new() + .with_file(PYTHON_CODE, None) + .with_lang_loader(helpers::test_syntax_loader(None)) + .build() + .unwrap(); + + test_key_sequence( + app, + Some(":fold --all --document"), + Some(&|app| { + let folded_python_code = fold_text(app); + match std::env::var("HELIX_UPDATE_FOLDED_PYTHON_CODE") { + Ok(_) => { + folded_python_code + .write_to(File::create(FOLDED_PYTHON_CODE).unwrap()) + .unwrap(); + } + Err(_) => { + let expected = + Rope::from_reader(File::open(FOLDED_PYTHON_CODE).unwrap()).unwrap(); + assert_eq!(folded_python_code, expected); + } + } + }), + false, + ) + .await +} + +#[tokio::test(flavor = "multi_thread")] +async fn fold_class() -> anyhow::Result<()> { + let app = &mut AppBuilder::new() + .with_file(RUST_CODE, None) + .with_lang_loader(helpers::test_syntax_loader(None)) + .build() + .unwrap(); + + // NOTE: positions are one-based indexing + // ((from, to), additional folds number) + type TestResult = ((Position, Position), isize); + + let prev_folds_number = Cell::new(0); + let result = |app: &Application| -> TestResult { + let (view, doc) = current_ref!(&app.editor); + let text = doc.text().slice(..); + + let (from, to) = { + let range = doc.selection(view.id).primary(); + positions_from_range(text, range) + }; + + let additional_folds_number = { + let folds_number = doc + .fold_container(view.id) + .map_or(0, |container| container.len()); + folds_number as isize - prev_folds_number.replace(folds_number) as isize + }; + + ((from, to), additional_folds_number) + }; + + test_key_sequences( + app, + vec![ + ( + Some( + "g10g\ + g5\ + |zf", + ), + Some(&|app| { + let expected = ((Position::new(10, 5), Position::new(10, 5)), 1); + assert_eq!(result(app), expected); + }), + ), + ( + Some( + "g48g\ + g2|\ + zf", + ), + Some(&|app| { + let expected = ((Position::new(48, 2), Position::new(48, 2)), 0); + assert_eq!(result(app), expected); + }), + ), + ( + Some( + "g46g\ + zf", + ), + Some(&|app| { + let expected = ((Position::new(44, 1), Position::new(44, 13)), 1); + assert_eq!(result(app), expected); + }), + ), + ( + Some( + "g105g\ + xx\ + zf", + ), + Some(&|app| { + let expected = ((Position::new(104, 17), Position::new(104, 36)), 1); + assert_eq!(result(app), expected); + }), + ), + ( + Some( + "g93g\ + v\ + g100g\ + :fold class", + ), + Some(&|app| { + let expected = ((Position::new(90, 9), Position::new(98, 20)), 2); + assert_eq!(result(app), expected); + }), + ), + ( + Some( + "g51g\ + g6|\ + zf", + ), + Some(&|app| { + let expected = ((Position::new(51, 6), Position::new(51, 6)), 1); + assert_eq!(result(app), expected); + }), + ), + ], + false, + ) + .await +} + +#[tokio::test(flavor = "multi_thread")] +async fn fold_function() -> anyhow::Result<()> { + let app = &mut AppBuilder::new() + .with_file(RUST_CODE, None) + .with_lang_loader(helpers::test_syntax_loader(None)) + .build() + .unwrap(); + + // NOTE: positions are one-based indexing + // ((from, to), additional folds number) + type TestResult = ((Position, Position), isize); + + let prev_folds_number = Cell::new(0); + let result = |app: &Application| -> TestResult { + let (view, doc) = current_ref!(&app.editor); + let text = doc.text().slice(..); + + let (from, to) = { + let range = doc.selection(view.id).primary(); + positions_from_range(text, range) + }; + + let additional_folds_number = { + let folds_number = doc + .fold_container(view.id) + .map_or(0, |container| container.len()); + folds_number as isize - prev_folds_number.replace(folds_number) as isize + }; + + ((from, to), additional_folds_number) + }; + + test_key_sequences( + app, + vec![ + ( + Some( + "g77g\ + v\ + g83g\ + :fold function", + ), + Some(&|app| { + let expected = ((Position::new(77, 1), Position::new(80, 20)), 1); + assert_eq!(result(app), expected); + }), + ), + ( + Some( + "g105g\ + g21|\ + :fold function", + ), + Some(&|app| { + let expected = ((Position::new(99, 13), Position::new(99, 19)), 1); + assert_eq!(result(app), expected); + }), + ), + ( + Some( + "g64g\ + v\ + g77g\ + :fold function", + ), + Some(&|app| { + let expected = ((Position::new(60, 5), Position::new(75, 22)), 2); + assert_eq!(result(app), expected); + }), + ), + ( + Some( + "g50g\ + mat\ + :fold function", + ), + Some(&|app| { + let expected = ((Position::new(50, 1), Position::new(115, 1)), 0); + assert_eq!(result(app), expected); + }), + ), + ( + Some( + "gg\ + :fold -a -d class comment", + ), + Some(&|app| { + let expected = ((Position::new(1, 1), Position::new(1, 1)), 0); + assert_eq!(result(app), expected); + }), + ), + ], + false, + ) + .await +} + +#[tokio::test(flavor = "multi_thread")] +async fn fold_comment() -> anyhow::Result<()> { + let app = &mut AppBuilder::new() + .with_file(RUST_CODE, None) + .with_lang_loader(helpers::test_syntax_loader(None)) + .build() + .unwrap(); + + // NOTE: positions are one-based indexing + // ((from, to), additional folds number) + type TestResult = ((Position, Position), isize); + + let prev_folds_number = Cell::new(0); + let result = |app: &Application| -> TestResult { + let (view, doc) = current_ref!(&app.editor); + let text = doc.text().slice(..); + + let (from, to) = { + let range = doc.selection(view.id).primary(); + positions_from_range(text, range) + }; + + let additional_folds_number = { + let folds_number = doc + .fold_container(view.id) + .map_or(0, |container| container.len()); + folds_number as isize - prev_folds_number.replace(folds_number) as isize + }; + + ((from, to), additional_folds_number) + }; + + test_key_sequences( + app, + vec![ + ( + Some( + "gg\ + zf", + ), + Some(&|app| { + let expected = ((Position::new(1, 1), Position::new(1, 1)), 1); + assert_eq!(result(app), expected); + }), + ), + ( + Some( + "g8g\ + g27|\ + zf", + ), + Some(&|app| { + let expected = ((Position::new(7, 5), Position::new(7, 27)), 1); + assert_eq!(result(app), expected); + }), + ), + ( + Some( + "g12g\ + g27| + zf", + ), + Some(&|app| { + let expected = ((Position::new(12, 27), Position::new(12, 27)), 1); + assert_eq!(result(app), expected); + }), + ), + ( + Some( + "g15g\ + g7| + zf", + ), + Some(&|app| { + let expected = ((Position::new(15, 7), Position::new(15, 7)), 0); + assert_eq!(result(app), expected); + }), + ), + ( + Some( + "g18g\ + g16|\ + v\ + g30g\ + g9|\ + :fold comment", + ), + Some(&|app| { + let expected = ((Position::new(18, 16), Position::new(29, 28)), 2); + assert_eq!(result(app), expected); + }), + ), + ( + Some( + "g32g\ + v\ + g37g + :fold comment", + ), + Some(&|app| { + let expected = ((Position::new(32, 1), Position::new(36, 17)), 1); + assert_eq!(result(app), expected); + }), + ), + ( + Some( + "g53g\ + g5| + zf", + ), + Some(&|app| { + let expected = ((Position::new(53, 5), Position::new(53, 5)), 1); + assert_eq!(result(app), expected); + }), + ), + ( + Some( + "g102g\ + g40|\ + zf", + ), + Some(&|app| { + let expected = ((Position::new(100, 17), Position::new(100, 41)), 1); + assert_eq!(result(app), expected); + }), + ), + ], + false, + ) + .await +} + +#[tokio::test(flavor = "multi_thread")] +async fn fold_selection() -> anyhow::Result<()> { + let app = &mut AppBuilder::new() + .with_file(RUST_CODE, None) + .with_lang_loader(helpers::test_syntax_loader(None)) + .build() + .unwrap(); + + // NOTE: positions are one-based indexing + // ((from, to), additional folds number) + type TestResult = ((Position, Position), isize); + + let prev_folds_number = Cell::new(0); + let result = |app: &Application| -> TestResult { + let (view, doc) = current_ref!(&app.editor); + let text = doc.text().slice(..); + + let (from, to) = { + let range = doc.selection(view.id).primary(); + positions_from_range(text, range) + }; + + let additional_folds_number = { + let folds_number = doc + .fold_container(view.id) + .map_or(0, |container| container.len()); + folds_number as isize - prev_folds_number.replace(folds_number) as isize + }; + + ((from, to), additional_folds_number) + }; + + test_key_sequences( + app, + vec![ + ( + Some( + "g2g\ + v\ + g10g\ + :fold -s", + ), + Some(&|app| { + let expected = ((Position::new(2, 1), Position::new(2, 22)), 1); + assert_eq!(result(app), expected); + }), + ), + ( + Some( + "g15g\ + v\ + g25g\ + :fold -s", + ), + Some(&|app| { + let expected = ((Position::new(15, 1), Position::new(15, 29)), 1); + assert_eq!(result(app), expected); + }), + ), + ( + Some( + "g11g\ + v\ + g14g\ + :fold -s", + ), + Some(&|app| { + let expected = ((Position::new(11, 1), Position::new(11, 8)), 1); + assert_eq!(result(app), expected); + }), + ), + ( + Some( + "g2g\ + v\ + 2j", + ), + Some(&|app| { + let (view, doc) = current_ref!(&app.editor); + let text = doc.text().slice(..); + + let range = doc.selection(view.id).primary(); + let (start, end) = range.line_range(text); + + let expected = (1, 14); + assert_eq!((start, end), expected, "select fold headers"); + }), + ), + ( + Some(":fold -s"), + Some(&|app| { + let expected = ((Position::new(2, 1), Position::new(2, 22)), -2); + assert_eq!(result(app), expected); + }), + ), + ( + Some( + "v\ + j", + ), + Some(&|app| { + let (view, doc) = current_ref!(&app.editor); + let text = doc.text().slice(..); + + let range = doc.selection(view.id).primary(); + let (start, end) = range.line_range(text); + + let expected = (1, 25); + assert_eq!((start, end), expected, "select fold"); + }), + ), + ], + false, + ) + .await +} + +#[tokio::test(flavor = "multi_thread")] +async fn fold() -> anyhow::Result<()> { + let app = &mut AppBuilder::new() + .with_file(RUST_CODE, None) + .with_lang_loader(helpers::test_syntax_loader(None)) + .build() + .unwrap(); + + // NOTE: positions are one-based indexing + // ((from, to), additional folds number) + type TestResult = ((Position, Position), isize); + + let prev_folds_number = Cell::new(0); + let result = |app: &Application| -> TestResult { + let (view, doc) = current_ref!(&app.editor); + let text = doc.text().slice(..); + + let (from, to) = { + let range = doc.selection(view.id).primary(); + positions_from_range(text, range) + }; + + let additional_folds_number = { + let folds_number = doc + .fold_container(view.id) + .map_or(0, |container| container.len()); + folds_number as isize - prev_folds_number.replace(folds_number) as isize + }; + + ((from, to), additional_folds_number) + }; + + test_key_sequences( + app, + vec![ + ( + Some( + "g5g\ + gl\ + mam\ + \ + v\ + gg\ + :fold -a", + ), + Some(&|app| { + let expected = ((Position::new(1, 1), Position::new(34, 1)), 7); + assert_eq!(result(app), expected); + }), + ), + ( + Some( + "g37g\ + v\ + g46g\ + zf", + ), + Some(&|app| { + let expected = ((Position::new(36, 1), Position::new(44, 13)), 3); + assert_eq!(result(app), expected); + }), + ), + ( + Some( + "g64g\ + v\ + g80g\ + zf", + ), + Some(&|app| { + let expected = ((Position::new(60, 5), Position::new(75, 22)), 4); + assert_eq!(result(app), expected); + }), + ), + ( + Some( + "g50g\ + mat\ + :fold comment", + ), + Some(&|app| { + let expected = ((Position::new(50, 1), Position::new(115, 1)), 6); + assert_eq!(result(app), expected); + }), + ), + ( + Some("zf"), + Some(&|app| { + let expected = ((Position::new(50, 1), Position::new(58, 13)), 6); + assert_eq!(result(app), expected); + }), + ), + ( + Some( + "g5g\ + g22|\ + mam\ + :fold -s", + ), + Some(&|app| { + let expected = ((Position::new(5, 22), Position::new(5, 23)), -5); + assert_eq!(result(app), expected); + }), + ), + ], + false, + ) + .await +} + +#[tokio::test(flavor = "multi_thread")] +async fn format() -> anyhow::Result<()> { + let app = &mut AppBuilder::new() + .with_file(RUST_CODE, None) + .with_lang_loader(helpers::test_syntax_loader(None)) + .build() + .unwrap(); + + let prev_folds_number = Cell::new(0); + test_key_sequences( + app, + vec![ + ( + Some(":fold -a -d"), + Some(&|app| { + let (view, doc) = current_ref!(&app.editor); + let container = doc + .fold_container(view.id) + .expect("Container must be initialized."); + + prev_folds_number.set(container.len()); + }), + ), + ( + Some(":format"), + Some(&|app| { + let (view, doc) = current_ref!(&app.editor); + let container = doc + .fold_container(view.id) + .expect("Container must be initialized."); + + assert_eq!( + container.len(), + prev_folds_number.get(), + "All folds must be retained." + ); + }), + ), + ], + false, + ) + .await +} + +#[tokio::test(flavor = "multi_thread")] +async fn unfold_class() -> anyhow::Result<()> { + let app = &mut AppBuilder::new() + .with_file(RUST_CODE, None) + .with_lang_loader(helpers::test_syntax_loader(None)) + .build() + .unwrap(); + + // NOTE: header is one-based indexing (row, col) + // (additional folds number, unexpected fold headers) + type TestResult = (isize, Vec); + + let prev_folds_number = Cell::new(0); + // NOTE: header is one-based indexing (row, col) + let result = |app: &Application, headers: &[(usize, usize)]| -> TestResult { + let (view, doc) = current_ref!(&app.editor); + + let text = doc.text().slice(..); + let container = doc + .fold_container(view.id) + .expect("Container must be initialized."); + + let additional_folds_number = { + let folds_number = container.len(); + folds_number as isize - prev_folds_number.replace(folds_number) as isize + }; + + let unexpected_fold_headers = container + .start_points() + .iter() + .map(|sfp| position_from_char(text, sfp.header)) + .filter(|&header| { + let Position { row, col } = header; + headers.contains(&(row, col)) + }) + .collect(); + + (additional_folds_number, unexpected_fold_headers) + }; + + test_key_sequences( + app, + vec![ + ( + Some(":fold -a -d"), + Some(&|app| { + let (view, doc) = current_ref!(&app.editor); + let container = doc + .fold_container(view.id) + .expect("Container must be initialized."); + + prev_folds_number.set(container.len()); + }), + ), + ( + Some( + "g15g\ + g6|\ + zF", + ), + Some(&|app| { + let (additional_folds_number, unexpected_fold_headers) = result(app, &[]); + + assert!( + unexpected_fold_headers.is_empty(), + "{unexpected_fold_headers:#?}" + ); + assert_eq!(additional_folds_number, 0); + }), + ), + ( + Some( + "g15g\ + g5| + zF", + ), + Some(&|app| { + let (additional_folds_number, unexpected_fold_headers) = + result(app, &[(10, 5)]); + + assert!( + unexpected_fold_headers.is_empty(), + "{unexpected_fold_headers:#?}" + ); + assert_eq!(additional_folds_number, -1); + }), + ), + ( + Some( + "g18g\ + g5| + :unfold class", + ), + Some(&|app| { + let (additional_folds_number, unexpected_fold_headers) = + result(app, &[(17, 17)]); + + assert!( + unexpected_fold_headers.is_empty(), + "{unexpected_fold_headers:#?}" + ); + assert_eq!(additional_folds_number, -1); + }), + ), + ( + Some( + "g35g\ + v\ + g49g\ + :unfold class", + ), + Some(&|app| { + let (additional_folds_number, unexpected_fold_headers) = + result(app, &[(44, 1)]); + + assert!( + unexpected_fold_headers.is_empty(), + "{unexpected_fold_headers:#?}" + ); + assert_eq!(additional_folds_number, -1); + }), + ), + ( + Some( + "g52g\ + g5| + zF", + ), + Some(&|app| { + let (additional_folds_number, unexpected_fold_headers) = + result(app, &[(50, 1)]); + + assert!( + unexpected_fold_headers.is_empty(), + "{unexpected_fold_headers:#?}" + ); + assert_eq!(additional_folds_number, -1); + }), + ), + ( + Some( + "g89g\ + v\ + g110g\ + :unfold -r class", + ), + Some(&|app| { + // NOTE: when navigating to line 89, the function `g` is unfolded + let (additional_folds_number, unexpected_fold_headers) = + result(app, &[(75, 5), (90, 9), (98, 9), (104, 17)]); + + assert!( + unexpected_fold_headers.is_empty(), + "{unexpected_fold_headers:#?}" + ); + assert_eq!(additional_folds_number, -4); + }), + ), + ], + false, + ) + .await +} + +#[tokio::test(flavor = "multi_thread")] +async fn unfold_function() -> anyhow::Result<()> { + let app = &mut AppBuilder::new() + .with_file(RUST_CODE, None) + .with_lang_loader(helpers::test_syntax_loader(None)) + .build() + .unwrap(); + + // NOTE: header is one-based indexing (row, col) + // (additional folds number, unexpected fold headers) + type TestResult = (isize, Vec); + + let prev_folds_number = Cell::new(0); + // NOTE: header is one-based indexing (row, col) + let result = |app: &Application, headers: &[(usize, usize)]| -> TestResult { + let (view, doc) = current_ref!(&app.editor); + + let text = doc.text().slice(..); + let container = doc + .fold_container(view.id) + .expect("Container must be initialized."); + + let additional_folds_number = { + let folds_number = container.len(); + folds_number as isize - prev_folds_number.replace(folds_number) as isize + }; + + let unexpected_fold_headers = container + .start_points() + .iter() + .map(|sfp| position_from_char(text, sfp.header)) + .filter(|&header| { + let Position { row, col } = header; + headers.contains(&(row, col)) + }) + .collect(); + + (additional_folds_number, unexpected_fold_headers) + }; + + test_key_sequences( + app, + vec![ + ( + Some(":fold -a -d class"), + Some(&|app| { + let (view, doc) = current_ref!(&app.editor); + let container = doc + .fold_container(view.id) + .expect("Container must be initialized."); + + prev_folds_number.set(container.len()); + }), + ), + ( + Some( + "g71g\ + g6|\ + zF", + ), + Some(&|app| { + let (additional_folds_number, unexpected_fold_headers) = result(app, &[]); + + assert!( + unexpected_fold_headers.is_empty(), + "{unexpected_fold_headers:#?}" + ); + assert_eq!(additional_folds_number, 0); + }), + ), + ( + Some( + "g71g\ + g5|\ + zF", + ), + Some(&|app| { + let (additional_folds_number, unexpected_fold_headers) = + result(app, &[(60, 5)]); + + assert!( + unexpected_fold_headers.is_empty(), + "{unexpected_fold_headers:#?}" + ); + assert_eq!(additional_folds_number, -1); + }), + ), + ( + Some( + "g50g\ + mat\ + :unfold -r function", + ), + Some(&|app| { + let (additional_folds_number, unexpected_fold_headers) = + result(app, &[(75, 5), (80, 9), (99, 13)]); + + assert!( + unexpected_fold_headers.is_empty(), + "{unexpected_fold_headers:#?}" + ); + assert_eq!(additional_folds_number, -3); + }), + ), + ], + false, + ) + .await +} + +#[tokio::test(flavor = "multi_thread")] +async fn unfold_comment() -> anyhow::Result<()> { + let app = &mut AppBuilder::new() + .with_file(RUST_CODE, None) + .with_lang_loader(helpers::test_syntax_loader(None)) + .build() + .unwrap(); + + // NOTE: header is one-based indexing (row, col) + // (additional folds number, unexpected fold headers) + type TestResult = (isize, Vec); + + let prev_folds_number = Cell::new(0); + // NOTE: header is one-based indexing (row, col) + let result = |app: &Application, headers: &[(usize, usize)]| -> TestResult { + let (view, doc) = current_ref!(&app.editor); + + let text = doc.text().slice(..); + let container = doc + .fold_container(view.id) + .expect("Container must be initialized."); + + let additional_folds_number = { + let folds_number = container.len(); + folds_number as isize - prev_folds_number.replace(folds_number) as isize + }; + + let unexpected_fold_headers = container + .start_points() + .iter() + .map(|sfp| position_from_char(text, sfp.header)) + .filter(|&header| { + let Position { row, col } = header; + headers.contains(&(row, col)) + }) + .collect(); + + (additional_folds_number, unexpected_fold_headers) + }; + + test_key_sequences( + app, + vec![ + ( + Some( + ":fold -a -d\ + g50g\ + mat\ + :unfold -r class\ + gg", + ), + Some(&|app| { + let (view, doc) = current_ref!(&app.editor); + let container = doc + .fold_container(view.id) + .expect("Container must be initialized."); + + prev_folds_number.set(container.len()); + }), + ), + ( + Some("zF"), + Some(&|app| { + let (additional_folds_number, unexpected_fold_headers) = result(app, &[(1, 1)]); + + assert!( + unexpected_fold_headers.is_empty(), + "{unexpected_fold_headers:#?}" + ); + assert_eq!(additional_folds_number, -1); + }), + ), + ( + Some( + "g5g\ + g22|\ + mam\ + :unfold comment", + ), + Some(&|app| { + let (additional_folds_number, unexpected_fold_headers) = + result(app, &[(7, 5), (18, 5)]); + + assert!( + unexpected_fold_headers.is_empty(), + "{unexpected_fold_headers:#?}" + ); + assert_eq!(additional_folds_number, -2); + }), + ), + ( + Some( + "g5g\ + g22|\ + mam\ + :unfold -r comment", + ), + Some(&|app| { + let (additional_folds_number, unexpected_fold_headers) = + result(app, &[(12, 13), (29, 9)]); + + assert!( + unexpected_fold_headers.is_empty(), + "{unexpected_fold_headers:#?}" + ); + assert_eq!(additional_folds_number, -2); + }), + ), + ( + Some( + "g17g\ + v\ + g40g\ + :unfold comment", + ), + Some(&|app| { + let (additional_folds_number, unexpected_fold_headers) = + result(app, &[(36, 1), (40, 1)]); + + assert!( + unexpected_fold_headers.is_empty(), + "{unexpected_fold_headers:#?}" + ); + assert_eq!(additional_folds_number, -2); + }), + ), + ( + Some( + "g61g\ + zF", + ), + Some(&|app| { + let (additional_folds_number, unexpected_fold_headers) = + result(app, &[(61, 1)]); + + assert!( + unexpected_fold_headers.is_empty(), + "{unexpected_fold_headers:#?}" + ); + assert_eq!(additional_folds_number, -1); + }), + ), + ( + Some( + "g71g\ + g7|\ + zF", + ), + Some(&|app| { + let (additional_folds_number, unexpected_fold_headers) = + result(app, &[(71, 7)]); + + assert!( + unexpected_fold_headers.is_empty(), + "{unexpected_fold_headers:#?}" + ); + assert_eq!(additional_folds_number, -1); + }), + ), + ( + Some( + "g75g\ + g5|\ + maf\ + :unfold -r comment", + ), + Some(&|app| { + // NOTE: when navigating to line 75, the function `g` is unfolded + let (additional_folds_number, unexpected_fold_headers) = + result(app, &[(75, 5), (76, 9), (90, 23), (100, 17), (111, 9)]); + + assert!( + unexpected_fold_headers.is_empty(), + "{unexpected_fold_headers:#?}" + ); + assert_eq!(additional_folds_number, -5); + }), + ), + ], + false, + ) + .await +} + +#[tokio::test(flavor = "multi_thread")] +async fn unfold_selection() -> anyhow::Result<()> { + let app = &mut AppBuilder::new() + .with_file(RUST_CODE, None) + .with_lang_loader(helpers::test_syntax_loader(None)) + .build() + .unwrap(); + + // NOTE: header is one-based indexing (row, col) + // (additional folds number, unexpected fold headers) + type TestResult = (isize, Vec); + + let prev_folds_number = Cell::new(0); + // NOTE: header is one-based indexing (row, col) + let result = |app: &Application, headers: &[(usize, usize)]| -> TestResult { + let (view, doc) = current_ref!(&app.editor); + + let text = doc.text().slice(..); + let container = doc + .fold_container(view.id) + .expect("Container must be initialized."); + + let additional_folds_number = { + let folds_number = container.len(); + folds_number as isize - prev_folds_number.replace(folds_number) as isize + }; + + let unexpected_fold_headers = container + .start_points() + .iter() + .map(|sfp| position_from_char(text, sfp.header)) + .filter(|&header| { + let Position { row, col } = header; + headers.contains(&(row, col)) + }) + .collect(); + + (additional_folds_number, unexpected_fold_headers) + }; + + test_key_sequences( + app, + vec![ + ( + Some( + "g2g\ + v\ + g5g\ + :fold -s\ + g7g\ + v\ + g10g\ + :fold -s\ + g12g\ + v\ + g15g\ + :fold -s\ + gg", + ), + Some(&|app| { + let (view, doc) = current_ref!(&app.editor); + let container = doc + .fold_container(view.id) + .expect("Container must be initialized."); + + prev_folds_number.set(container.len()); + }), + ), + ( + Some( + "g2g\ + :unfold -s", + ), + Some(&|app| { + let (additional_folds_number, unexpected_fold_headers) = result(app, &[(2, 1)]); + + assert!( + unexpected_fold_headers.is_empty(), + "{unexpected_fold_headers:#?}" + ); + assert_eq!(additional_folds_number, -1); + }), + ), + ( + Some( + "g7g\ + v\ + g12g\ + :unfold -s", + ), + Some(&|app| { + let (additional_folds_number, unexpected_fold_headers) = + result(app, &[(7, 1), (12, 1)]); + + assert!( + unexpected_fold_headers.is_empty(), + "{unexpected_fold_headers:#?}" + ); + assert_eq!(additional_folds_number, -2); + }), + ), + ], + false, + ) + .await +} + +#[tokio::test(flavor = "multi_thread")] +async fn unfold() -> anyhow::Result<()> { + let app = &mut AppBuilder::new() + .with_file(RUST_CODE, None) + .with_lang_loader(helpers::test_syntax_loader(None)) + .build() + .unwrap(); + + // NOTE: header is one-based indexing (row, col) + // (additional folds number, unexpected fold headers) + type TestResult = (isize, Vec); + + let prev_folds_number = Cell::new(0); + // NOTE: header is one-based indexing (row, col) + let result = |app: &Application, headers: &[(usize, usize)]| -> TestResult { + let (view, doc) = current_ref!(&app.editor); + + let text = doc.text().slice(..); + let container = doc + .fold_container(view.id) + .expect("Container must be initialized."); + + let additional_folds_number = { + let folds_number = container.len(); + folds_number as isize - prev_folds_number.replace(folds_number) as isize + }; + + let unexpected_fold_headers = container + .start_points() + .iter() + .map(|sfp| position_from_char(text, sfp.header)) + .filter(|&header| { + let Position { row, col } = header; + headers.contains(&(row, col)) + }) + .collect(); + + (additional_folds_number, unexpected_fold_headers) + }; + + test_key_sequences( + app, + vec![ + ( + Some(":fold -a -d"), + Some(&|app| { + let (view, doc) = current_ref!(&app.editor); + let container = doc + .fold_container(view.id) + .expect("Container must be initialized."); + + prev_folds_number.set(container.len()); + }), + ), + ( + Some( + "g5g\ + g22|\ + mam\ + \ + v\ + gg\ + :unfold -a", + ), + Some(&|app| { + let (additional_folds_number, unexpected_fold_headers) = + result(app, &[(1, 1), (7, 5), (10, 5), (17, 17), (18, 5)]); + + assert!( + unexpected_fold_headers.is_empty(), + "{unexpected_fold_headers:#?}" + ); + assert_eq!(additional_folds_number, -5); + }), + ), + ( + Some( + "g50g\ + v\ + g116g\ + :unfold -a -r comment", + ), + Some(&|app| { + let (additional_folds_number, unexpected_fold_headers) = result( + app, + &[ + (50, 1), + (50, 5), + (75, 5), + (80, 9), + (90, 9), + (98, 9), + (99, 13), + (104, 17), + ], + ); + + assert!( + unexpected_fold_headers.is_empty(), + "{unexpected_fold_headers:#?}" + ); + assert_eq!(additional_folds_number, -8); + }), + ), + ( + Some(":unfold -a -d -r"), + Some(&|app| { + let (additional_folds_number, unexpected_fold_headers) = result( + app, + &[ + (12, 13), + (29, 9), + (36, 1), + (40, 1), + (44, 1), + (53, 5), + (56, 5), + (61, 1), + (71, 7), + (76, 9), + (90, 23), + (100, 17), + (111, 9), + ], + ); + + assert!( + unexpected_fold_headers.is_empty(), + "{unexpected_fold_headers:#?}" + ); + assert_eq!(additional_folds_number, -13); + }), + ), + ], + false, + ) + .await +} + +#[tokio::test(flavor = "multi_thread")] +async fn open() -> anyhow::Result<()> { + let app = &mut AppBuilder::new() + .with_file(RUST_CODE, None) + .with_lang_loader(helpers::test_syntax_loader(None)) + .build() + .unwrap(); + + // (text of the new line, additional folds number) + type TestResult<'a> = (String, isize); + + let prev_folds_number = Cell::new(0); + // NOTE: new_line is one-based indexing + let result = |app: &Application, new_line: usize| -> TestResult { + let (view, doc) = current_ref!(&app.editor); + let text = doc.text().slice(..); + let container = doc + .fold_container(view.id) + .expect("Container must be initialized."); + + let additional_folds_number = { + let folds_number = container.len(); + folds_number as isize - prev_folds_number.replace(folds_number) as isize + }; + + let new_line = text.line(new_line - 1).as_str().unwrap(); + + (new_line.into(), additional_folds_number) + }; + + test_key_sequences( + app, + vec![ + ( + Some(":fold -a -d class"), + Some(&|app| { + let (view, doc) = current_ref!(&app.editor); + let container = doc + .fold_container(view.id) + .expect("Container must be initialized."); + + prev_folds_number.set(container.len()); + }), + ), + ( + Some( + "g53g\ + o\ + new text", + ), + Some(&|app| { + let expected = (" new text\n".into(), 0); + assert_eq!(result(app, 55), expected); + }), + ), + (Some("xd"), None), + ( + Some( + "g55g\ + O\ + new text", + ), + Some(&|app| { + let expected = (" new text\n".into(), 0); + assert_eq!(result(app, 55), expected); + }), + ), + (Some("xd"), None), + ( + Some( + "g63g\ + o\ + new text", + ), + Some(&|app| { + let expected = (" new text\n".into(), -1); + assert_eq!(result(app, 71), expected); + }), + ), + ( + Some("xdzf"), + Some(&|app| { + let (view, doc) = current_ref!(&app.editor); + let container = doc + .fold_container(view.id) + .expect("Container must be initialized."); + + prev_folds_number.set(container.len()); + }), + ), + ( + Some( + "g71g\ + O\ + new text", + ), + Some(&|app| { + let expected = (" new text\n".into(), -1); + assert_eq!(result(app, 71), expected); + }), + ), + ( + Some( + "gg\ + o\ + new text", + ), + Some(&|app| { + let expected = ("new text\n".into(), 0); + assert_eq!(result(app, 4), expected); + }), + ), + ( + Some( + "xd\ + gg\ + zF", + ), + Some(&|app| { + let (view, doc) = current_ref!(&app.editor); + let container = doc + .fold_container(view.id) + .expect("Container must be initialized."); + + prev_folds_number.set(container.len()); + }), + ), + ( + Some( + "g3g\ + o\ + new text", + ), + Some(&|app| { + let expected = ("//! new text\n".into(), 0); + assert_eq!(result(app, 4), expected); + }), + ), + ], + false, + ) + .await +} + +#[tokio::test(flavor = "multi_thread")] +async fn default_folding() -> anyhow::Result<()> { + use helix_view::editor::LspConfig; + + let config = Config { + editor: helix_view::editor::Config { + fold_textobjects: vec!["class".into(), "function".into()], + lsp: LspConfig { + // suppress lsp error + enable: false, + ..Default::default() + }, + ..Default::default() + }, + ..Default::default() + }; + + let app = &mut AppBuilder::new() + .with_file(RUST_CODE, None) + .with_lang_loader(helpers::test_syntax_loader(None)) + .with_config(config) + .build() + .unwrap(); + + test_key_sequence( + app, + None, + Some(&|app| { + let (view, doc) = current_ref!(&app.editor); + let container = doc + .fold_container(view.id) + .expect("Container must be initialized."); + + let folds_number = container.len(); + assert_eq!(folds_number, 11); + }), + false, + ) + .await +} + +#[tokio::test(flavor = "multi_thread")] +async fn toggle_fold() -> anyhow::Result<()> { + let app = &mut AppBuilder::new() + .with_file(RUST_CODE, None) + .with_lang_loader(helpers::test_syntax_loader(None)) + .build() + .unwrap(); + + let folds_number = |app: &Application| { + let (view, doc) = current_ref!(&app.editor); + doc.fold_container(view.id) + .map_or(0, |container| container.len()) + }; + + test_key_sequences( + app, + vec![ + ( + Some("z"), + Some(&|app| assert_eq!(folds_number(app), 1)), + ), + ( + Some("z"), + Some(&|app| assert_eq!(folds_number(app), 0)), + ), + ( + Some("g7gz"), + Some(&|app| assert_eq!(folds_number(app), 0)), + ), + ( + Some("g5|z"), + Some(&|app| assert_eq!(folds_number(app), 1)), + ), + ( + Some("z"), + Some(&|app| assert_eq!(folds_number(app), 0)), + ), + ( + Some("g10gg5|z"), + Some(&|app| assert_eq!(folds_number(app), 1)), + ), + ( + Some("z"), + Some(&|app| assert_eq!(folds_number(app), 0)), + ), + ( + Some("g12gg13|z"), + Some(&|app| assert_eq!(folds_number(app), 1)), + ), + ( + Some("z"), + Some(&|app| assert_eq!(folds_number(app), 0)), + ), + ( + Some("g17gg17|z"), + Some(&|app| assert_eq!(folds_number(app), 1)), + ), + ( + Some("z"), + Some(&|app| assert_eq!(folds_number(app), 0)), + ), + ( + Some("g93gg13|z"), + Some(&|app| assert_eq!(folds_number(app), 1)), + ), + ( + Some("z"), + Some(&|app| assert_eq!(folds_number(app), 0)), + ), + ( + Some("g105gg21|z"), + Some(&|app| assert_eq!(folds_number(app), 1)), + ), + ( + Some("z"), + Some(&|app| assert_eq!(folds_number(app), 0)), + ), + ], + false, + ) + .await +} diff --git a/helix-term/tests/test/commands/text_folding/folded-python-code b/helix-term/tests/test/commands/text_folding/folded-python-code new file mode 100644 index 000000000000..08c0d71bf963 --- /dev/null +++ b/helix-term/tests/test/commands/text_folding/folded-python-code @@ -0,0 +1,24 @@ +# top comment + + +class Fizz: + + +# comment +def f(a, b): + """ + doc comment + doc comment + doc comment + """ + + class Nested: + + def nested(a, b): + # interfering comment + """ + + c = a + b + d = a + b + c + # nested comment + return c + d # interfering comment \ No newline at end of file diff --git a/helix-term/tests/test/commands/text_folding/folded-rust-code b/helix-term/tests/test/commands/text_folding/folded-rust-code new file mode 100644 index 000000000000..d7d9721f2206 --- /dev/null +++ b/helix-term/tests/test/commands/text_folding/folded-rust-code @@ -0,0 +1,28 @@ +//! top-level comment + +mod format_is_needed { + + /// `Bazz` doc comment + pub struct Bazz { + } // interfering comment + + pub struct Fizz + // `where` block comment +where +T: Copy, +U: Copy, + V: Copy, +} + +/* block comment + +/// `TraitA` doc comment +trait TraitA { + +impl TraitA for format_is_needed::Fizz +where + T: Copy, + /* block comment + U: Copy, + /* block comment + V: Copy, diff --git a/helix-term/tests/test/commands/text_folding/python-code.py b/helix-term/tests/test/commands/text_folding/python-code.py new file mode 100644 index 000000000000..ca8b7fba64c4 --- /dev/null +++ b/helix-term/tests/test/commands/text_folding/python-code.py @@ -0,0 +1,75 @@ +# top comment +# top comment +# top comment + + +class Fizz: + def __init__(self, a): + """ + doc comment + doc comment + doc comment + """ + b = a + a + self.b = b + + def f(self): + a = self.b // 2 + c = a + b + + +# comment +# comment +# comment +def f(a, b): + """ + doc comment + doc comment + doc comment + """ + + class Nested: + def __init__(self, b): + self.b = b + # really nested comment + # really nested comment + # really nested comment + print("log") + + def f(self): + """ + really nested doc comment + really nested doc comment + really nested doc comment + """ + + class ReallyNested: + def f(self): + print(1 + 1) + print(2 + 2) + + def g(self): + print(1 + 1) + print(2 + 2) + + print("log") + print(f"b = {self.b}") # interfering comment + + def nested(a, b): + # interfering comment + # interfering comment + # interfering comment + """ + nested doc comment + nested doc comment + nested doc comment + """ + print("log") + print(a + b) + + c = a + b + d = a + b + c + # nested comment + # nested comment + # nested comment + return c + d # interfering comment \ No newline at end of file diff --git a/helix-term/tests/test/commands/text_folding/rust-code.rs b/helix-term/tests/test/commands/text_folding/rust-code.rs new file mode 100644 index 000000000000..fc639eba2335 --- /dev/null +++ b/helix-term/tests/test/commands/text_folding/rust-code.rs @@ -0,0 +1,115 @@ +//! top-level comment +//! top-level comment +//! top-level comment + +mod format_is_needed { + + /// `Bazz` doc comment + /// `Bazz` doc comment + /// `Bazz` doc comment + pub struct Bazz { +g: i32, + // `b` comment + // `b` comment +b: i32, + } // interfering comment + + pub struct Fizz + // `where` block comment + // `where` block comment + // `where` block comment +where +T: Copy, +U: Copy, + V: Copy, + { + // interfering comment + a : T , + b : U , + /// `c` doc comment + /// `c` doc comment + /// `c` doc comment + c: V, + } +} + +/* block comment +block comment +block comment */ + +/// `TraitA` doc comment +/// `TraitA` doc comment +/// `TraitA` doc comment +/// `TraitA` doc comment +trait TraitA { + fn f(self, a: u32, b: u32) -> u32; + + fn g(self) -> u32; +} + +impl TraitA for format_is_needed::Fizz +where + T: Copy, + /* block comment + block comment */ + U: Copy, + /* block comment + block comment */ + V: Copy, +{ + fn f(self, a: u32, b: u32) -> u32 +/* interfering block comment + interfering block comment + interfering block comment */ { + todo!( + " + write some code + write some code + write some code + " + ); + } // interfering comment + // interfering comment + // interfering comment + + fn g(self) -> u32 { + // comment inside function + // comment inside function + // comment inside function + + fn nested() { + todo!( + " + write some code + write some code + write some code + " + ); + } + + struct Nested /* interfering block comment + interfering block comment + interfering block comment */ { + a: i32, + b: i32, + c: i32, + } + + impl Nested { + fn h() { + // really nested comment + // really nested comment + // really nested comment + + struct ReallyNested { + a: i32, + b: i32, + } + } + } + + /* block comment inside function + block comment inside function + block comment inside function */ + } +} diff --git a/helix-view/src/document.rs b/helix-view/src/document.rs index e52dbe0f956f..7a9dd2342b13 100644 --- a/helix-view/src/document.rs +++ b/helix-view/src/document.rs @@ -12,6 +12,7 @@ use helix_core::encoding::Encoding; use helix_core::snippets::{ActiveSnippet, SnippetRenderCtx}; use helix_core::syntax::config::LanguageServerFeature; use helix_core::text_annotations::{InlineAnnotation, Overlay}; +use helix_core::text_folding::{EndFoldPoint, FoldContainer, StartFoldPoint}; use helix_event::TaskController; use helix_lsp::util::lsp_pos_to_pos; use helix_stdx::faccess::{copy_metadata, readonly}; @@ -150,6 +151,7 @@ pub struct Document { /// To know if they're up-to-date, check the `id` field in `DocumentInlayHints`. pub(crate) inlay_hints: HashMap, pub(crate) jump_labels: HashMap>, + fold_container: HashMap, /// Set to `true` when the document is updated, reset to `false` on the next inlay hints /// update from the LSP pub inlay_hints_oudated: bool, @@ -317,6 +319,7 @@ impl fmt::Debug for Document { .field("modified_since_accessed", &self.modified_since_accessed) .field("diagnostics", &self.diagnostics) // .field("language_server", &self.language_server) + .field("fold_container", &self.fold_container) .finish() } } @@ -704,6 +707,7 @@ impl Document { text, selections: HashMap::default(), inlay_hints: HashMap::default(), + fold_container: HashMap::default(), inlay_hints_oudated: false, view_data: Default::default(), indent_style: DEFAULT_INDENT, @@ -1330,6 +1334,15 @@ impl Document { // TODO: use a transaction? self.selections .insert(view_id, selection.ensure_invariants(self.text().slice(..))); + + if let Some((container, selection)) = self + .fold_container + .get_mut(&view_id) + .zip(self.selections.get(&view_id)) + { + container.remove_by_selection(self.text.slice(..), selection) + } + helix_event::dispatch(SelectionDidChange { doc: self, view: view_id, @@ -1393,14 +1406,10 @@ impl Document { if changes.is_empty() { if let Some(selection) = transaction.selection() { - self.selections.insert( + self.set_selection( view_id, selection.clone().ensure_invariants(self.text.slice(..)), ); - helix_event::dispatch(SelectionDidChange { - doc: self, - view: view_id, - }); } return true; } @@ -1408,13 +1417,23 @@ impl Document { self.modified_since_accessed = true; self.version += 1; - for selection in self.selections.values_mut() { - *selection = selection + for container in self.fold_container.values_mut() { + container.update_by_transaction(self.text.slice(..), old_doc.slice(..), transaction); + } + + for (id, selection) in &mut self.selections { + let ensured_selection = selection .clone() // Map through changes .map(transaction.changes()) // Ensure all selections across all views still adhere to invariants. .ensure_invariants(self.text.slice(..)); + + if let Some(container) = self.fold_container.get_mut(id) { + container.remove_by_selection(self.text.slice(..), &ensured_selection); + } + + *selection = ensured_selection; } for view_data in self.view_data.values_mut() { @@ -1535,14 +1554,10 @@ impl Document { // if specified, the current selection should instead be replaced by transaction.selection if let Some(selection) = transaction.selection() { - self.selections.insert( + self.set_selection( view_id, selection.clone().ensure_invariants(self.text.slice(..)), ); - helix_event::dispatch(SelectionDidChange { - doc: self, - view: view_id, - }); } true @@ -2293,6 +2308,61 @@ impl Document { pub fn has_language_server_with_feature(&self, feature: LanguageServerFeature) -> bool { self.language_servers_with_feature(feature).next().is_some() } + + pub fn insert_fold_container(&mut self, view_id: ViewId, container: FoldContainer) { + self.fold_container.insert(view_id, container); + } + + /// `None` when container is empty. + pub fn fold_container(&self, view_id: ViewId) -> Option<&FoldContainer> { + self.fold_container + .get(&view_id) + .filter(|container| !container.is_empty()) + } + + fn add_folds_impl( + &mut self, + view: &View, + fold_points: Vec<(StartFoldPoint, EndFoldPoint)>, + replace: bool, + ) { + let text = self.text.slice(..); + let range = self.selection(view.id).primary(); + let container = self.fold_container.entry(view.id).or_default(); + + if replace { + container.replace(text, fold_points); + } else { + container.add(text, fold_points); + } + + let range = container.throw_range_out_of_folds(text, range); + self.set_selection(view.id, Selection::single(range.anchor, range.head)); + + let scrolloff = self.config.load().scrolloff; + view.ensure_cursor_in_view(self, scrolloff); + } + + pub fn add_folds(&mut self, view: &View, fold_points: Vec<(StartFoldPoint, EndFoldPoint)>) { + self.add_folds_impl(view, fold_points, false); + } + + pub fn replace_folds(&mut self, view: &View, fold_points: Vec<(StartFoldPoint, EndFoldPoint)>) { + self.add_folds_impl(view, fold_points, true); + } + + pub fn remove_folds(&mut self, view: &View, start_indices: Vec) { + let text = self.text.slice(..); + let container = self + .fold_container + .get_mut(&view.id) + .expect("Container must be initialized"); + + container.remove(text, start_indices); + + let scrolloff = self.config.load().scrolloff; + view.ensure_cursor_in_view(self, scrolloff); + } } #[derive(Debug, Default)] diff --git a/helix-view/src/editor.rs b/helix-view/src/editor.rs index 7f8cff9c3e44..8721a89456ce 100644 --- a/helix-view/src/editor.rs +++ b/helix-view/src/editor.rs @@ -427,6 +427,8 @@ pub struct Config { pub rainbow_brackets: bool, /// Whether to enable Kitty Keyboard Protocol pub kitty_keyboard_protocol: KittyKeyboardProtocolConfig, + /// Defines which text objects will be folded when a document is opened. + pub fold_textobjects: Vec, } #[derive(Debug, Default, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize, Clone, Copy)] @@ -1118,6 +1120,7 @@ impl Default for Config { editor_config: true, rainbow_brackets: false, kitty_keyboard_protocol: Default::default(), + fold_textobjects: Vec::new(), } } } diff --git a/helix-view/src/gutter.rs b/helix-view/src/gutter.rs index 7506e5156374..33303807859a 100644 --- a/helix-view/src/gutter.rs +++ b/helix-view/src/gutter.rs @@ -1,6 +1,7 @@ use std::fmt::Write; use helix_core::syntax::config::LanguageServerFeature; +use helix_core::text_folding::Fold; use crate::{ editor::GutterType, @@ -148,8 +149,9 @@ pub fn line_numbers<'doc>( ) -> GutterFn<'doc> { let text = doc.text().slice(..); let width = line_numbers_width(view, doc); + let annotations = view.text_annotations(doc, None); - let last_line_in_view = view.estimate_last_doc_line(doc); + let last_line_in_view = view.estimate_last_doc_line(&annotations, doc); // Whether to draw the line number for the last line of the // document or not. We only draw it if it's not an empty line. @@ -165,13 +167,54 @@ pub fn line_numbers<'doc>( let line_number = editor.config().line_number; let mode = editor.mode; + // folded lines between `line` and `current_line` + let mut folded_lines = None; + let folded_lines_of = |fold: Fold| fold.end.line - fold.start.line + 1; + Box::new( move |line: usize, selected: bool, first_visual_line: bool, out: &mut String| { + let fold_annotations = &annotations.folds; + if line == last_line_in_view && !draw_last { write!(out, "{:>1$}", '~', width).unwrap(); Some(linenr) } else { use crate::{document::Mode, editor::LineNumber}; + use std::cmp::Ordering::*; + use std::cmp::{max, min}; + + // calculate folded lines + match current_line.cmp(&line) { + Greater if folded_lines.is_none() => { + fold_annotations.reset_pos(line, |fold| fold.start.line); + let line_range = min(current_line, line)..=max(current_line, line); + folded_lines = Some(fold_annotations.folded_lines_between(&line_range)); + } + Greater => match folded_lines { + Some(n) if n > 0 => { + if let Some(fold) = + fold_annotations.consume_next(line, |fold| fold.end.line + 1) + { + folded_lines = Some(n - folded_lines_of(fold)) + } + } + _ => (), + }, + Equal => { + if folded_lines.is_none() { + fold_annotations.reset_pos(line, |fold| fold.start.line); + } + fold_annotations.consume_next(line, |fold| fold.end.line + 1); + folded_lines = Some(0); + } + Less => { + if let Some(fold) = + fold_annotations.consume_next(line, |fold| fold.end.line + 1) + { + folded_lines = Some(folded_lines.unwrap() + folded_lines_of(fold)); + } + } + } let relative = line_number == LineNumber::Relative && mode != Mode::Insert @@ -179,7 +222,26 @@ pub fn line_numbers<'doc>( && current_line != line; let display_num = if relative { - current_line.abs_diff(line) + match current_line + .abs_diff(line) + .checked_sub(folded_lines.unwrap_or(0)) + { + Some(r) => r, + None => { + let msg = format!( + "`folded_lines` can not exceed the abs between `current_line` and `line`\n\ + \tcurrent_line = {current_line}; \ + \tline = {line}; \ + \tfolded_lines = {folded_lines:?}" + ); + if cfg!(debug_assertions) { + panic!("{msg}"); + } else { + log::error!("{msg}"); + current_line.abs_diff(line) + } + } + } } else { line + 1 }; diff --git a/helix-view/src/handlers/word_index.rs b/helix-view/src/handlers/word_index.rs index 9c3f83384b23..cda3c3fe3f59 100644 --- a/helix-view/src/handlers/word_index.rs +++ b/helix-view/src/handlers/word_index.rs @@ -6,7 +6,8 @@ use std::{borrow::Cow, collections::HashMap, iter, mem, sync::Arc, time::Duration}; use helix_core::{ - chars::char_is_word, fuzzy::fuzzy_match, movement, ChangeSet, Range, Rope, RopeSlice, + chars::char_is_word, fuzzy::fuzzy_match, movement, text_annotations::TextAnnotations, + ChangeSet, Range, Rope, RopeSlice, }; use helix_event::{register_hook, AsyncHook}; use helix_stdx::rope::RopeSliceExt as _; @@ -263,7 +264,8 @@ fn words(text: RopeSlice) -> impl Iterator { .get_char(cursor.anchor) .is_some_and(|ch| !ch.is_whitespace()) { - let cursor_word_end = movement::move_next_word_end(text, cursor, 1); + let cursor_word_end = + movement::move_next_word_end(text, &TextAnnotations::default(), cursor, 1); if cursor_word_end.anchor == 0 { cursor = cursor_word_end; } @@ -290,7 +292,7 @@ fn words(text: RopeSlice) -> impl Iterator { } } let head = cursor.head; - cursor = movement::move_next_word_end(text, cursor, 1); + cursor = movement::move_next_word_end(text, &TextAnnotations::default(), cursor, 1); if cursor.head == head { cursor.head = usize::MAX; } diff --git a/helix-view/src/view.rs b/helix-view/src/view.rs index aecf09a610ed..dc6382938a07 100644 --- a/helix-view/src/view.rs +++ b/helix-view/src/view.rs @@ -12,6 +12,7 @@ use helix_core::{ char_idx_at_visual_offset, doc_formatter::TextFormat, text_annotations::TextAnnotations, + text_folding::{FoldAnnotations, RopeSliceFoldExt}, visual_offset_from_anchor, visual_offset_from_block, Position, RopeSlice, Selection, Transaction, VisualOffsetError::{PosAfterMaxRow, PosBeforeAnchorRow}, @@ -356,13 +357,14 @@ impl View { /// The actual last visible line may be smaller if softwrapping occurs /// or virtual text lines are visible #[inline] - pub fn estimate_last_doc_line(&self, doc: &Document) -> usize { + pub fn estimate_last_doc_line(&self, annotations: &TextAnnotations, doc: &Document) -> usize { let doc_text = doc.text().slice(..); let line = doc_text.char_to_line(doc.view_offset(self.id).anchor.min(doc_text.len_chars())); - // Saturating subs to make it inclusive zero indexing. - (line + self.inner_height()) - .min(doc_text.len_lines()) - .saturating_sub(1) + doc_text.nth_next_folded_line( + &annotations.folds, + line, + self.inner_height().saturating_sub(1), + ) } /// Calculates the last non-empty visual line on screen @@ -378,7 +380,7 @@ impl View { let visual_height = doc.view_offset(self.id).vertical_offset + viewport.height as usize; // fast path when the EOF is not visible on the screen, - if self.estimate_last_doc_line(doc) < doc_text.len_lines() - 1 { + if self.estimate_last_doc_line(&annotations, doc) < doc_text.len_lines() - 1 { return visual_height.saturating_sub(1); } @@ -510,9 +512,17 @@ impl View { )); } + if let Some(fold_container) = doc.fold_container(self.id) { + text_annotations.add_folds(fold_container); + } + text_annotations } + pub fn fold_annotations<'a>(&self, doc: &'a Document) -> FoldAnnotations<'a> { + FoldAnnotations::new(doc.fold_container(self.id)) + } + pub fn text_pos_at_screen_coords( &self, doc: &Document, diff --git a/theme.toml b/theme.toml index 2139c8bfd082..6daa7dd99938 100644 --- a/theme.toml +++ b/theme.toml @@ -62,6 +62,7 @@ tabstop = { modifiers = ["italic"], bg = "bossanova" } "ui.virtual.jump-label" = { fg = "apricot", modifiers = ["bold"] } "ui.virtual.indent-guide" = { fg = "comet" } +"ui.virtual.fold-decoration" = { bg = "revolver", fg = "honey", modifiers = ["italic"] } "ui.selection" = { bg = "#540099" } "ui.selection.primary" = { bg = "#540099" }