use crate::ropetext::motion::{self, Goal, Words};
use crate::ropetext::{Change, Column, EditBuffer as Rope, Position, Span, Text};
const DEFAULT_INDENT_WIDTH: std::num::NonZeroU8 = std::num::NonZeroU8::new(4).unwrap();
#[must_use = "an edit's outcome drives the revision bump and the parse-damage signal"]
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct EditOutcome {
pub changed: bool,
pub bulk: bool,
pub damage: Option<std::ops::Range<usize>>,
pub line_delta: isize,
}
pub(super) fn shift_rows(
range: std::ops::Range<usize>,
at: usize,
delta: isize,
) -> std::ops::Range<usize> {
let shift = |row: usize| {
if row < at {
row
} else {
row.saturating_add_signed(delta)
}
};
shift(range.start)..shift(range.end)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Yank {
Keep,
Discard,
}
#[derive(Debug, Clone, Copy)]
enum RowEdit {
Insert(Position),
Delete(Span),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CursorMove {
Forward,
Back,
Up,
Down,
Head,
End,
Top,
Bottom,
WordForward,
WordBack,
WordEnd,
WordForwardBig,
WordBackBig,
WordEndBig,
WordEndBack {
big: bool,
},
MatchingPair,
ParagraphForward,
ParagraphBack,
Jump(usize, usize),
}
impl CursorMove {
fn is_vertical(self) -> bool {
matches!(self, CursorMove::Up | CursorMove::Down)
}
}
#[derive(Debug)]
pub struct RopeBuffer {
inner: Rope,
pending: EditOutcome,
depth: u32,
group_started: bool,
continue_group: bool,
goal: Option<Column>,
yank: String,
search: Option<regex::Regex>,
indent_width: std::num::NonZeroU8,
}
impl Default for RopeBuffer {
fn default() -> Self {
Self::new(Text::new())
}
}
impl RopeBuffer {
pub fn new(text: Text) -> Self {
Self {
inner: Rope::new(text),
pending: EditOutcome::default(),
depth: 0,
group_started: false,
continue_group: false,
goal: None,
yank: String::new(),
search: None,
indent_width: DEFAULT_INDENT_WIDTH,
}
}
pub fn replace(&mut self, text: Text) {
self.inner.set_text(text);
self.pending = EditOutcome::default();
self.goal = None;
}
pub fn text(&self) -> &Text {
self.inner.text()
}
#[cfg(test)]
pub fn set_indent_width(&mut self, spaces: u8) {
self.indent_width =
std::num::NonZeroU8::new(spaces).expect("an indent step is at least one space");
}
pub fn snapshot(&self) -> crate::ropetext::Snapshot {
self.inner.snapshot()
}
pub fn row(&self, row: usize) -> Option<std::borrow::Cow<'_, str>> {
self.inner.text().line(row)
}
pub fn row_count(&self) -> usize {
self.inner.text().line_count()
}
pub fn rows(&self) -> Vec<String> {
self.inner.text().lines().map(|l| l.to_string()).collect()
}
pub fn joined_rows(&self, first: usize, last: usize) -> String {
(first..=last)
.filter_map(|row| self.row(row))
.collect::<Vec<_>>()
.join("\n")
}
pub fn row_len(&self, row: usize) -> usize {
self.inner.text().line_len_chars(row).unwrap_or(0)
}
pub fn cursor(&self) -> (usize, usize) {
let cursor = self.inner.cursor();
(cursor.row(), cursor.column().get())
}
pub fn is_empty(&self) -> bool {
self.inner.text().len_bytes() == 0
}
pub fn selection_range(&self) -> Option<((usize, usize), (usize, usize))> {
let span = self.inner.selection()?;
Some((rc(span.start()), rc(span.end())))
}
pub fn yank_text(&self) -> String {
self.yank.clone()
}
pub fn set_yank_text(&mut self, text: impl Into<String>) {
self.yank = text.into();
}
pub fn search_pattern(&self) -> Option<®ex::Regex> {
self.search.as_ref()
}
pub fn take_outcome(&mut self) -> EditOutcome {
std::mem::take(&mut self.pending)
}
pub fn edit<R>(&mut self, f: impl FnOnce(&mut Self) -> R) -> R {
if self.depth > 0 {
return f(self);
}
self.depth = 1;
self.group_started = false;
let out = f(self);
self.depth = 0;
self.group_started = false;
out
}
pub fn continue_group(&mut self) {
self.continue_group = true;
}
fn mutate(&mut self, f: impl FnOnce(&mut crate::ropetext::Txn<'_>)) -> bool {
let extending =
(self.depth > 0 && self.group_started) || std::mem::take(&mut self.continue_group);
let mut txn = if extending {
self.inner.begin_extending()
} else {
self.inner.begin()
};
f(&mut txn);
let change = txn.commit();
if self.depth > 0 {
self.group_started = true;
}
self.record(change)
}
fn record(&mut self, change: Option<Change>) -> bool {
let Some(change) = change else {
return false;
};
self.pending.changed = true;
self.pending.bulk |= change.is_bulk();
self.pending.line_delta += change.line_delta();
self.pending.damage = Some(match self.pending.damage.take() {
Some(seen) => {
let seen = shift_rows(seen, change.rows().start, change.line_delta());
seen.start.min(change.rows().start)..seen.end.max(change.rows().end)
}
None => change.rows(),
});
true
}
pub fn insert_str(&mut self, s: impl AsRef<str>) -> bool {
let text = s.as_ref().to_string();
let span = self.inner.selection().filter(|span| !span.is_empty());
self.inner.clear_selection();
let cursor = self.inner.cursor();
self.goal = None;
self.mutate(|txn| match span {
Some(span) => {
txn.replace(span, &text);
}
None => {
txn.insert(cursor, &text);
}
})
}
pub fn insert_char(&mut self, c: char) {
self.insert_str(c.to_string());
}
pub fn insert_newline(&mut self) {
self.insert_str("\n");
}
pub fn delete_str(&mut self, clusters: usize) -> bool {
if self.take_selection() {
return true;
}
if clusters == 0 {
return false;
}
let from = self.inner.cursor();
let to = self.forward_by(from, clusters);
self.delete_between(from, to, Yank::Keep)
}
pub fn delete_char(&mut self) -> bool {
if self.take_selection() {
return true;
}
let to = self.inner.cursor();
let from = motion::prev_cluster(self.inner.text(), to);
self.delete_between(from, to, Yank::Discard)
}
pub fn delete_next_char(&mut self) -> bool {
if self.take_selection() {
return true;
}
let from = self.inner.cursor();
let to = motion::next_cluster(self.inner.text(), from);
self.delete_between(from, to, Yank::Discard)
}
pub fn delete_word(&mut self) -> bool {
if self.take_selection() {
return true;
}
let to = self.inner.cursor();
let text = self.inner.text();
let candidate = motion::word_start_back(text, to, Words::Small);
let (from, yank) = if candidate.row() == to.row() && candidate.byte() < to.byte() {
(candidate, Yank::Keep)
} else if to.column().get() > 0 {
(motion::row_start(text, to), Yank::Keep)
} else {
(motion::prev_cluster(text, to), Yank::Discard)
};
self.delete_between(from, to, yank)
}
pub fn delete_next_word(&mut self) -> bool {
if self.take_selection() {
return true;
}
let from = self.inner.cursor();
let text = self.inner.text();
let candidate = motion::word_end_at_or_after(text, from, Words::Small);
let row_end = motion::row_end(text, from);
let (to, yank) = match candidate {
Some(end) if end.row() == from.row() && end.byte() > from.byte() => (end, Yank::Keep),
_ if from.byte() < row_end.byte() => (row_end, Yank::Keep),
_ => (motion::next_cluster(text, from), Yank::Discard),
};
self.delete_between(from, to, yank)
}
pub fn cut(&mut self) -> bool {
let span = self.inner.selection().filter(|span| !span.is_empty());
self.inner.clear_selection();
let Some(span) = span else {
return false;
};
self.yank = self
.inner
.text()
.slice(span)
.map(|text| text.to_string())
.unwrap_or_default();
self.goal = None;
self.mutate(|txn| {
txn.delete(span);
})
}
pub fn copy(&mut self) {
if let Some(span) = self.inner.selection().filter(|span| !span.is_empty())
&& let Some(text) = self.inner.text().slice(span)
{
self.yank = text.to_string();
}
}
pub fn paste(&mut self) -> bool {
if self.yank.is_empty() {
return false;
}
let text = std::mem::take(&mut self.yank);
let changed = self.insert_str(&text);
self.yank = text;
changed
}
fn take_selection(&mut self) -> bool {
let span = self.inner.selection().filter(|span| !span.is_empty());
self.inner.clear_selection();
let Some(span) = span else {
return false;
};
self.delete_between(span.start(), span.end(), Yank::Discard)
}
fn delete_between(&mut self, from: Position, to: Position, yank: Yank) -> bool {
let Some(span) = self.inner.text().span(from, to) else {
return false;
};
if span.is_empty() {
return false;
}
if yank == Yank::Keep
&& let Some(text) = self.inner.text().slice(span)
{
self.yank = text.to_string();
}
self.goal = None;
self.mutate(|txn| {
txn.delete(span);
})
}
pub fn undo(&mut self) -> bool {
let change = self.inner.undo();
self.after_history(change)
}
pub fn redo(&mut self) -> bool {
let change = self.inner.redo();
self.after_history(change)
}
fn after_history(&mut self, change: Option<Change>) -> bool {
if change.is_none() {
return false;
}
self.goal = None;
self.inner.clear_selection();
self.record(change)
}
pub fn move_cursor(&mut self, movement: CursorMove) {
let text = self.inner.text();
let from = self.inner.cursor();
let goal = self
.goal
.filter(|_| movement.is_vertical())
.unwrap_or_else(|| from.column());
let to = match movement {
CursorMove::Forward => motion::next_cluster(text, from),
CursorMove::Back => motion::prev_cluster(text, from),
CursorMove::Up => motion::vertical(text, from, -1, Goal::Column(goal)),
CursorMove::Down => motion::vertical(text, from, 1, Goal::Column(goal)),
CursorMove::Head => motion::row_start(text, from),
CursorMove::End => motion::row_end(text, from),
CursorMove::Top => {
let up = -(from.row() as isize);
motion::vertical(text, from, up, Goal::Column(goal))
}
CursorMove::Bottom => {
let down = (text.line_count().saturating_sub(1) as isize) - from.row() as isize;
motion::vertical(text, from, down, Goal::Column(goal))
}
CursorMove::WordForward => motion::word_start_forward(text, from, Words::Small),
CursorMove::WordBack => motion::word_start_back(text, from, Words::Small),
CursorMove::WordForwardBig => motion::word_start_forward(text, from, Words::Big),
CursorMove::WordBackBig => motion::word_start_back(text, from, Words::Big),
CursorMove::WordEndBig => match motion::word_end_forward(text, from, Words::Big) {
Some(end) => motion::prev_cluster(text, end),
None => from,
},
CursorMove::WordEndBack { big } => {
let words = if big { Words::Big } else { Words::Small };
match motion::word_end_back(text, from, words) {
Some(end) => motion::prev_cluster(text, end),
None => from,
}
}
CursorMove::MatchingPair => motion::matching_bracket(text, from).unwrap_or(from),
CursorMove::WordEnd => match motion::word_end_forward(text, from, Words::Small) {
Some(end) => motion::prev_cluster(text, end),
None => motion::text_end(text),
},
CursorMove::ParagraphForward => motion::paragraph_forward(text, from),
CursorMove::ParagraphBack => motion::paragraph_back(text, from),
CursorMove::Jump(row, column) => {
match text.position(row, Column::new(column)) {
Some(position) => position,
None => return,
}
}
};
self.goal = if movement.is_vertical() {
Some(goal)
} else {
None
};
self.place(to);
}
pub fn jump_to(&mut self, row: usize, col: usize) -> bool {
let Some(to) = self.inner.text().position(row, Column::new(col)) else {
return false;
};
self.goal = None;
self.place(to);
true
}
pub fn move_to(&mut self, to: Position) {
if self.inner.text().is_stale(to) {
return;
}
self.goal = None;
self.place(to);
}
fn place(&mut self, to: Position) {
if self.inner.selection().is_some() {
self.inner.extend_to(to);
} else {
self.inner.set_cursor(to);
}
}
pub fn start_selection(&mut self) {
let cursor = self.inner.cursor();
self.inner.clear_selection();
self.inner.extend_to(cursor);
}
pub fn cancel_selection(&mut self) {
self.inner.clear_selection();
}
pub fn select_all(&mut self) {
let span = self.inner.text().full_span();
self.inner.select(span);
}
pub fn set_selection(&mut self, start: (usize, usize), end: (usize, usize)) -> bool {
let text = self.inner.text();
let Some(from) = text.position(start.0, Column::new(start.1)) else {
return false;
};
let Some(to) = text.position(end.0, Column::new(end.1)) else {
return false;
};
let Some(span) = text.span(from, to) else {
return false;
};
self.inner.select(span)
}
pub fn text_between(&self, start: (usize, usize), end: (usize, usize)) -> Option<String> {
let span = self
.span_between(start, end)
.filter(|span| !span.is_empty())?;
self.inner.text().slice(span).map(|text| text.into_owned())
}
pub fn selection_text(&self) -> Option<String> {
let (start, end) = self.selection_range()?;
self.text_between(start, end)
}
pub fn indent_rows(&mut self, rows: std::ops::RangeInclusive<usize>, dedent: bool) -> bool {
let step = self.indent_width.get() as usize;
let first = *rows.start();
let last = (*rows.end()).min(self.row_count() - 1);
if first > last {
return false;
}
let text = self.inner.text();
let mut edits: Vec<RowEdit> = Vec::with_capacity(last - first + 1);
let mut deltas: Vec<isize> = Vec::with_capacity(last - first + 1);
for row in first..=last {
let at = text
.position(row, Column::new(0))
.expect("row is within the buffer");
if dedent {
let count = self.leading_step(row);
if count > 0
&& let Some(span) = text.span(at, self.forward_by(at, count))
{
edits.push(RowEdit::Delete(span));
}
deltas.push(-(count as isize));
} else {
edits.push(RowEdit::Insert(at));
deltas.push(step as isize);
}
}
if edits.is_empty() {
return false;
}
let selection = self.selection_range();
let shifted = |(row, col): (usize, usize)| -> (usize, usize) {
if row < first || row > last {
return (row, col);
}
(row, col.saturating_add_signed(deltas[row - first]))
};
let spaces = " ".repeat(step);
self.goal = None;
self.mutate(|txn| {
for edit in &edits {
match *edit {
RowEdit::Insert(at) => {
txn.insert(at, &spaces);
}
RowEdit::Delete(span) => {
txn.delete(span);
}
}
}
if let Some((start, end)) = selection {
let (start, end) = (shifted(start), shifted(end));
let text = txn.text();
if let Some(from) = text.position(start.0, Column::new(start.1))
&& let Some(to) = text.position(end.0, Column::new(end.1))
&& let Some(span) = text.span(from, to)
{
txn.select(span);
}
}
})
}
fn leading_step(&self, row: usize) -> usize {
use unicode_segmentation::UnicodeSegmentation;
let step = self.indent_width.get() as usize;
let Some(line) = self.row(row) else {
return 0;
};
let mut count = 0;
for cluster in line.graphemes(true).take(step) {
match cluster {
" " => count += 1,
"\t" => return count + 1,
_ => break,
}
}
count
}
pub fn set_search_pattern(&mut self, pattern: &str) -> Result<(), regex::Error> {
if pattern.is_empty() {
self.search = None;
return Ok(());
}
self.search = Some(regex::Regex::new(pattern)?);
Ok(())
}
pub fn search_forward(&mut self, match_cursor: bool) -> bool {
self.step_search(false, match_cursor)
}
pub fn search_back(&mut self, match_cursor: bool) -> bool {
self.step_search(true, match_cursor)
}
pub fn search_repeat(&mut self, backward: bool) -> bool {
self.step_search(backward, false)
}
fn step_search(&mut self, backward: bool, match_cursor: bool) -> bool {
self.cancel_selection();
let Some(found) = self.find_match(backward, match_cursor) else {
return false;
};
self.goal = None;
self.inner.set_cursor(found);
true
}
fn find_match(&self, backward: bool, match_cursor: bool) -> Option<Position> {
let pattern = self.search.as_ref()?;
let text = self.inner.text();
let cursor = self.inner.cursor();
let rows = text.line_count();
for step in 0..=rows {
let wrapped = step == rows;
let row = if backward {
(cursor.row() + rows - (step % rows.max(1))) % rows
} else {
(cursor.row() + step) % rows
};
let line = text.line(row)?;
let mut hits: Vec<usize> = pattern
.find_iter(&line)
.map(|found| line[..found.start()].chars().count())
.collect();
if backward {
hits.reverse();
}
for column in hits {
let same_row = row == cursor.row();
let beyond = if backward {
column < cursor.column().get()
} else if match_cursor {
column >= cursor.column().get()
} else {
column > cursor.column().get()
};
if wrapped || !same_row || beyond {
if let Some(at) = text.position(row, Column::new(column)) {
return Some(at);
}
}
}
}
None
}
pub fn match_at_cursor(&self) -> Option<((usize, usize), (usize, usize))> {
let pattern = self.search.as_ref()?;
let text = self.inner.text();
let cursor = self.inner.cursor();
let line = text.line(cursor.row())?;
let byte = line
.char_indices()
.nth(cursor.column().get())
.map(|(at, _)| at)
.unwrap_or(line.len());
let found = pattern.find_at(&line, byte)?;
if found.start() != byte {
return None;
}
let chars = line[found.range()].chars().count();
Some((rc(cursor), (cursor.row(), cursor.column().get() + chars)))
}
fn forward_by(&self, from: Position, chars: usize) -> Position {
let text = self.inner.text();
let mut at = from;
for _ in 0..chars {
let next = motion::next_cluster(text, at);
if next.byte() == at.byte() {
break;
}
at = next;
}
at
}
fn span_between(&self, start: (usize, usize), end: (usize, usize)) -> Option<Span> {
let text = self.inner.text();
let from = text.position(start.0, Column::new(start.1))?;
let to = text.position(end.0, Column::new(end.1))?;
text.span(from, to)
}
}
fn rc(position: Position) -> (usize, usize) {
(position.row(), position.column().get())
}
#[cfg(test)]
mod search_tests {
use super::*;
use crate::ropetext::Text;
fn buffer(text: &str, pattern: &str, cursor: (usize, usize)) -> RopeBuffer {
let mut buf = RopeBuffer::new(Text::from(text));
buf.set_search_pattern(pattern).expect("valid pattern");
buf.move_cursor(CursorMove::Jump(cursor.0, cursor.1));
buf
}
#[test]
fn a_forward_search_wraps_to_a_match_behind_the_cursor() {
let mut buf = buffer("xx foo", "foo", (0, 5));
assert!(buf.search_forward(false), "the match is behind the cursor");
assert_eq!(buf.cursor(), (0, 3));
}
#[test]
fn a_backward_search_wraps_to_a_match_ahead_of_the_cursor() {
let mut buf = buffer("xx foo", "foo", (0, 1));
assert!(buf.search_back(false));
assert_eq!(buf.cursor(), (0, 3));
}
#[test]
fn wrapping_crosses_rows_back_to_the_cursors_own_row() {
let mut buf = buffer("aaa\nxx foo", "foo", (1, 5));
assert!(buf.search_forward(false));
assert_eq!(buf.cursor(), (1, 3));
}
#[test]
fn the_only_match_is_re_offered_rather_than_reported_missing() {
let mut buf = buffer("xx foo", "foo", (0, 3));
assert!(buf.search_forward(false), "the one match is still a match");
assert_eq!(
buf.cursor(),
(0, 3),
"and the cursor has nowhere else to go"
);
}
#[test]
fn a_match_starting_inside_a_cluster_is_skipped_not_fatal() {
let mut buf = buffer(
"\u{1F469}\u{200D}\u{1F4BB}\nx\u{1F4BB}",
"\u{1F4BB}",
(0, 0),
);
assert!(buf.search_forward(false), "the row 1 match is reachable");
assert_eq!(buf.cursor(), (1, 1));
}
}
#[cfg(test)]
mod cluster_tests {
use super::*;
use crate::ropetext::Text;
#[test]
fn delete_str_spends_its_count_on_clusters() {
let mut buf = RopeBuffer::new(Text::from("[[\u{1F1EA}\u{1F1F8} rest"));
buf.move_cursor(CursorMove::Jump(0, 0));
buf.delete_str(3);
assert_eq!(buf.rows(), &[" rest"]);
}
#[test]
fn inserting_before_a_combining_mark_keeps_the_cursor_addressable() {
let mut buf = RopeBuffer::new(Text::from("\u{301}f"));
buf.move_cursor(CursorMove::Jump(0, 0));
buf.insert_char('a');
assert_eq!(buf.rows(), &["a\u{301}f"]);
}
}
#[cfg(test)]
mod damage_tests {
use super::*;
use crate::ropetext::Text;
#[test]
fn damage_from_several_edits_is_in_one_numbering() {
let mut buf = RopeBuffer::new(Text::from("r0\nr1\nr2\nr3\nr4"));
buf.edit(|b| {
b.move_cursor(CursorMove::Jump(4, 0));
b.insert_str("X");
b.move_cursor(CursorMove::Jump(0, 0));
b.insert_newline();
});
assert_eq!(buf.rows(), ["", "r0", "r1", "r2", "r3", "Xr4"]);
let damage = buf.take_outcome().damage.expect("the edits were reported");
assert!(
damage.contains(&5),
"the row edited first is row 5 once the group ends, but the damage \
reported was {damage:?} — a range in the older numbering"
);
}
}
#[cfg(test)]
mod read_tests {
use super::*;
use crate::ropetext::Text;
#[test]
fn text_between_slices_by_char_columns_across_rows_in_either_order() {
let buf = RopeBuffer::new(Text::from("héllo🦀\nworld"));
assert_eq!(
buf.text_between((0, 1), (1, 2)).as_deref(),
Some("éllo🦀\nwo")
);
assert_eq!(
buf.text_between((1, 2), (0, 1)).as_deref(),
Some("éllo🦀\nwo")
);
}
#[test]
fn an_empty_or_unaddressable_range_is_none() {
let buf = RopeBuffer::new(Text::from("abc"));
assert_eq!(buf.text_between((0, 1), (0, 1)), None);
assert_eq!(buf.text_between((0, 0), (7, 0)), None);
}
#[test]
fn selection_text_is_the_live_selection_or_none() {
let mut buf = RopeBuffer::new(Text::from("hello world"));
assert_eq!(buf.selection_text(), None);
assert!(buf.set_selection((0, 0), (0, 5)));
assert_eq!(buf.selection_text().as_deref(), Some("hello"));
buf.start_selection();
assert_eq!(
buf.selection_text(),
None,
"a zero-width selection is not text"
);
}
}
#[cfg(test)]
mod indent_tests {
use super::*;
use crate::ropetext::Text;
fn buffer(text: &str) -> RopeBuffer {
RopeBuffer::new(Text::from(text))
}
#[test]
fn indent_inserts_one_step_of_spaces_never_a_tab() {
let mut buf = buffer("foo\nbar");
assert!(buf.indent_rows(0..=1, false));
assert_eq!(buf.rows(), &[" foo", " bar"]);
}
#[test]
fn indent_follows_indent_width() {
let mut buf = buffer("x");
buf.set_indent_width(2);
assert!(buf.indent_rows(0..=0, false));
assert_eq!(buf.rows(), &[" x"]);
}
#[test]
fn dedent_removes_up_to_one_step_of_spaces() {
let mut buf = buffer(" x\n y\nz");
assert!(buf.indent_rows(0..=2, true));
assert_eq!(buf.rows(), &[" x", "y", "z"]);
}
#[test]
fn dedent_counts_a_leading_tab_as_a_whole_step() {
let mut buf = buffer("\t\tx\n \ty");
assert!(buf.indent_rows(0..=1, true));
assert_eq!(buf.rows(), &["\tx", "y"]);
}
#[test]
fn dedent_stops_at_a_space_that_carries_a_combining_mark() {
let mut buf = buffer(" \u{301}foo");
assert!(buf.set_selection((0, 3), (0, 6)));
assert!(buf.indent_rows(0..=0, true));
assert_eq!(buf.rows(), &[" \u{301}foo"]);
assert_eq!(buf.selection_range(), Some(((0, 2), (0, 5))));
}
#[test]
fn dedent_with_nothing_to_remove_reports_no_change() {
let mut buf = buffer("foo");
assert!(!buf.indent_rows(0..=0, true));
assert_eq!(buf.rows(), &["foo"]);
assert!(!buf.take_outcome().changed);
}
#[test]
fn rows_past_the_end_are_ignored() {
let mut buf = buffer("a\nb");
assert!(buf.indent_rows(1..=9, false));
assert_eq!(buf.rows(), &["a", " b"]);
assert!(!buf.indent_rows(5..=9, false));
assert_eq!(buf.rows(), &["a", " b"]);
}
#[test]
fn the_cursor_keeps_its_character() {
let mut buf = buffer("one\ntwo");
assert!(buf.jump_to(0, 1)); buf.indent_rows(0..=1, false);
assert_eq!(buf.cursor(), (0, 1 + 4));
buf.indent_rows(0..=1, true);
assert_eq!(buf.cursor(), (0, 1));
}
#[test]
fn a_cursor_outside_the_rows_does_not_move() {
let mut buf = buffer("a\nb\nc");
assert!(buf.jump_to(2, 1));
buf.indent_rows(0..=1, false);
assert_eq!(buf.cursor(), (2, 1));
}
#[test]
fn a_dedent_never_pushes_the_cursor_below_column_zero() {
let mut buf = buffer(" x");
assert!(buf.jump_to(0, 2));
buf.indent_rows(0..=0, true);
assert_eq!(buf.cursor(), (0, 0));
}
#[test]
fn the_selection_is_put_back_shifted_with_its_rows() {
let mut buf = buffer("hello world\nnext");
assert!(buf.set_selection((0, 6), (1, 2)));
buf.indent_rows(0..=1, false);
assert_eq!(buf.selection_range(), Some(((0, 10), (1, 6))));
assert_eq!(buf.rows()[0].trim_start(), "hello world");
}
#[test]
fn a_dedent_under_a_selection_shifts_each_endpoint_by_its_own_row() {
let mut buf = buffer(" foo\n bar\nbaz");
assert!(buf.set_selection((0, 4), (2, 3)));
assert!(buf.indent_rows(0..=2, true));
assert_eq!(buf.rows(), &["foo", "bar", "baz"]);
assert_eq!(buf.selection_range(), Some(((0, 0), (2, 3))));
}
#[test]
fn a_block_indent_is_one_undo_group() {
let mut buf = buffer("a\nb\nc");
buf.indent_rows(0..=2, false);
assert!(buf.undo(), "the block is one entry");
assert_eq!(buf.rows(), &["a", "b", "c"]);
assert!(!buf.undo(), "and has nothing left to take back");
}
#[test]
fn undo_and_redo_of_an_indent_land_the_cursor_on_its_own_character() {
let mut buf = buffer("one\ntwo");
assert!(buf.jump_to(0, 1)); buf.indent_rows(0..=1, false);
assert_eq!(buf.cursor(), (0, 5));
assert!(buf.undo());
assert_eq!(buf.cursor(), (0, 1), "undo returns to where the user was");
assert!(buf.redo());
assert_eq!(
buf.cursor(),
(0, 5),
"redo lands where the indent left them"
);
}
#[test]
fn a_block_indent_is_one_transaction() {
let mut buf = buffer("a\nb\nc");
buf.indent_rows(0..=2, false);
let outcome = buf.take_outcome();
assert!(outcome.changed);
assert!(outcome.bulk, "three rows in one change");
assert!(buf.undo());
assert_eq!(buf.rows(), &["a", "b", "c"]);
assert!(!buf.undo());
}
#[test]
fn a_dedent_does_not_fill_the_yank_transport() {
let mut buf = buffer(" x");
buf.set_yank_text("kept");
buf.indent_rows(0..=0, true);
assert_eq!(buf.yank_text(), "kept");
}
#[test]
fn damage_covers_every_touched_row() {
let mut buf = buffer("a\nb\nc\nd");
buf.indent_rows(1..=2, false);
let outcome = buf.take_outcome();
assert!(outcome.changed);
let damage = outcome.damage.expect("the edits were reported");
assert!(damage.contains(&1) && damage.contains(&2), "{damage:?}");
assert_eq!(outcome.line_delta, 0);
}
}