use crate::config::Config;
use crate::editor::buffer::Buffer;
use crate::editor::cursor::Position;
use crate::editor::document::indent;
use crate::editor::selection::Range;
use crate::editor::window::Window;
use crate::undo::Change;
#[derive(Debug)]
pub struct Edit<'a> {
pub buffer: &'a mut Buffer,
pub window: &'a mut Window,
}
impl<'a> Edit<'a> {
#[must_use]
pub fn new(buffer: &'a mut Buffer, window: &'a mut Window) -> Self {
Self { buffer, window }
}
pub fn insert_text(&mut self, text: &str) {
if text.is_empty() {
return;
}
let length = text.chars().count();
for index in self.window.edit_order() {
let before = self.window.cursors()[index].head;
let at = self.buffer.document.pos_to_char(before);
self.buffer.document.insert(at, text);
let after = self.buffer.document.char_to_pos(at + length);
self.buffer
.history
.record(Change::insertion(at, text), before, after);
self.window.cursors_mut()[index].move_to(after, false);
}
self.invalidate_from_first_cursor();
self.window.resort();
}
pub fn insert_newline(&mut self, config: &Config) {
for index in self.window.edit_order() {
let before = self.window.cursors()[index].head;
let at = self.buffer.document.pos_to_char(before);
let mut text = String::from("\n");
if config.auto_indent {
text.push_str(&indent::auto_indent_for_new_line(
&self.buffer.document,
before.line,
before.col,
config.tab_width,
config.expand_tabs,
));
}
self.buffer.document.insert(at, &text);
let after = self.buffer.document.char_to_pos(at + text.chars().count());
self.buffer
.history
.record(Change::insertion(at, text.as_str()), before, after);
self.window.cursors_mut()[index].move_to(after, false);
}
self.invalidate_from_first_cursor();
self.window.resort();
}
pub fn insert_indent(&mut self, config: &Config) {
if !config.expand_tabs {
self.insert_text("\t");
return;
}
for index in self.window.edit_order() {
let before = self.window.cursors()[index].head;
let width = config.tab_width - (before.col % config.tab_width);
let text = " ".repeat(width);
let at = self.buffer.document.pos_to_char(before);
self.buffer.document.insert(at, &text);
let after = self.buffer.document.char_to_pos(at + width);
self.buffer
.history
.record(Change::insertion(at, text.as_str()), before, after);
self.window.cursors_mut()[index].move_to(after, false);
}
self.invalidate_from_first_cursor();
self.window.resort();
}
pub fn delete_backward(&mut self, config: &Config) {
for index in self.window.edit_order() {
let before = self.window.cursors()[index].head;
let at = self.buffer.document.pos_to_char(before);
if at == 0 {
continue;
}
let start = at - self.backspace_width(before, config);
let removed = self.buffer.document.remove(start, at);
let after = self.buffer.document.char_to_pos(start);
self.buffer
.history
.record(Change::deletion(start, removed), before, after);
self.window.cursors_mut()[index].move_to(after, false);
}
self.invalidate_from_first_cursor();
self.window.resort();
}
pub fn delete_forward(&mut self) {
for index in self.window.edit_order() {
let before = self.window.cursors()[index].head;
let at = self.buffer.document.pos_to_char(before);
if at >= self.buffer.document.len_chars() {
continue;
}
let removed = self.buffer.document.remove(at, at + 1);
let after = self.buffer.document.char_to_pos(at);
self.buffer
.history
.record(Change::deletion(at, removed), before, after);
self.window.cursors_mut()[index].move_to(after, false);
}
self.invalidate_from_first_cursor();
self.window.resort();
}
pub fn delete_range(&mut self, range: Range) -> String {
if range.is_empty() {
return String::new();
}
let before = self.window.cursor().head;
let removed = self.buffer.document.remove(range.start, range.end);
let after = self.buffer.document.char_to_pos(range.start);
self.buffer.history.record(
Change::deletion(range.start, removed.clone()),
before,
after,
);
self.window.clear_secondary_cursors();
self.window.cursor_mut().move_to(after, false);
self.buffer.invalidate_syntax_from(after.line);
self.buffer.history.checkpoint();
removed
}
pub fn paste(&mut self, text: &str, line_wise: bool) {
if text.is_empty() {
return;
}
if !line_wise {
self.insert_text(text);
self.checkpoint();
return;
}
let before = self.window.cursor().head;
let next_line = before.line + 1;
let (at, payload) = if next_line <= self.buffer.document.last_line() {
let mut payload = text.to_string();
if !payload.ends_with('\n') {
payload.push('\n');
}
(self.buffer.document.line_start(next_line), payload)
} else {
let payload = format!("\n{}", text.trim_end_matches('\n'));
(self.buffer.document.len_chars(), payload)
};
self.buffer.document.insert(at, &payload);
let landed = self
.buffer
.document
.char_to_pos(at + usize::from(payload.starts_with('\n')));
self.buffer
.history
.record(Change::insertion(at, payload.as_str()), before, landed);
self.window.clear_secondary_cursors();
self.window.cursor_mut().move_to(landed, false);
self.buffer.invalidate_syntax_from(before.line);
self.checkpoint();
}
pub fn trim_trailing_whitespace(&mut self) -> usize {
let before = self.window.cursor().head;
let mut trimmed = 0;
self.buffer.history.checkpoint();
for line in (0..self.buffer.document.len_lines()).rev() {
let text = self.buffer.document.line_string(line);
let kept = text.trim_end_matches([' ', '\t']).chars().count();
let length = text.chars().count();
if kept == length {
continue;
}
let start = self.buffer.document.line_start(line) + kept;
let removed = self.buffer.document.remove(start, start + (length - kept));
let after = self.buffer.document.char_to_pos(start);
self.buffer
.history
.record(Change::deletion(start, removed), before, after);
trimmed += 1;
}
if trimmed > 0 {
self.buffer.invalidate_syntax_from(0);
self.window.clamp_cursors(&self.buffer.document, true);
self.buffer.history.checkpoint();
}
trimmed
}
pub fn undo(&mut self) -> bool {
let Some(position) = self.buffer.history.undo(&mut self.buffer.document) else {
return false;
};
self.restore_caret(position);
true
}
pub fn redo(&mut self) -> bool {
let Some(position) = self.buffer.history.redo(&mut self.buffer.document) else {
return false;
};
self.restore_caret(position);
true
}
pub fn checkpoint(&mut self) {
self.buffer.history.checkpoint();
}
fn invalidate_from_first_cursor(&mut self) {
let line = self.window.cursors()[0].head.line;
self.buffer.invalidate_syntax_from(line);
}
fn backspace_width(&self, position: Position, config: &Config) -> usize {
if !config.expand_tabs || position.col == 0 {
return 1;
}
let leading_spaces = self
.buffer
.document
.line(position.line)
.chars()
.take(position.col)
.all(|ch| ch == ' ');
if !leading_spaces {
return 1;
}
let step = (position.col - 1) % config.tab_width + 1;
step.min(position.col)
}
fn restore_caret(&mut self, position: Position) {
self.window.clear_secondary_cursors();
let clamped = self.buffer.document.clamp(position, true);
self.window.cursor_mut().move_to(clamped, false);
self.buffer.invalidate_syntax_from(clamped.line);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::editor::cursor::Cursor;
use crate::editor::document::Document;
struct Fixture {
buffer: Buffer,
window: Window,
}
impl Fixture {
fn new(text: &str) -> Self {
Self {
buffer: Buffer::new(Document::from_text(text, None)),
window: Window::new(0),
}
}
fn edit(&mut self) -> Edit<'_> {
Edit::new(&mut self.buffer, &mut self.window)
}
fn text(&self) -> String {
self.buffer.document.text().to_string()
}
fn head(&self) -> Position {
self.window.cursor().head
}
fn caret_to(&mut self, line: usize, col: usize) {
self.window
.cursor_mut()
.move_to(Position::new(line, col), false);
}
}
fn config() -> Config {
Config::default()
}
#[test]
fn typing_moves_the_caret_along() {
let mut fixture = Fixture::new("");
fixture.edit().insert_text("hi");
assert_eq!(fixture.text(), "hi");
assert_eq!(fixture.head(), Position::new(0, 2));
}
#[test]
fn newline_carries_the_indentation_over() {
let mut fixture = Fixture::new(" let x = 1;");
fixture.caret_to(0, 14);
fixture.edit().insert_newline(&config());
assert_eq!(fixture.text(), " let x = 1;\n ");
assert_eq!(fixture.head(), Position::new(1, 4));
}
#[test]
fn newline_after_an_opening_brace_indents_one_more_level() {
let mut fixture = Fixture::new("fn main() {");
fixture.caret_to(0, 11);
fixture.edit().insert_newline(&config());
assert_eq!(fixture.text(), "fn main() {\n ");
}
#[test]
fn tab_aligns_to_the_next_tab_stop() {
let mut fixture = Fixture::new("ab");
fixture.caret_to(0, 2);
fixture.edit().insert_indent(&config());
assert_eq!(fixture.text(), "ab ");
}
#[test]
fn backspace_removes_a_whole_indent_step() {
let mut fixture = Fixture::new(" x");
fixture.caret_to(0, 8);
fixture.edit().delete_backward(&config());
assert_eq!(fixture.text(), " x");
}
#[test]
fn backspace_inside_a_word_removes_one_character() {
let mut fixture = Fixture::new("word");
fixture.caret_to(0, 4);
fixture.edit().delete_backward(&config());
assert_eq!(fixture.text(), "wor");
}
#[test]
fn backspace_at_the_start_of_a_line_joins_it_to_the_previous_one() {
let mut fixture = Fixture::new("ab\ncd");
fixture.caret_to(1, 0);
fixture.edit().delete_backward(&config());
assert_eq!(fixture.text(), "abcd");
assert_eq!(fixture.head(), Position::new(0, 2));
}
#[test]
fn multiple_cursors_all_receive_the_edit() {
let mut fixture = Fixture::new("a\na\na");
fixture.window.add_cursor(Cursor::at(Position::new(1, 0)));
fixture.window.add_cursor(Cursor::at(Position::new(2, 0)));
fixture.edit().insert_text("x");
assert_eq!(fixture.text(), "xa\nxa\nxa");
assert_eq!(fixture.window.cursors().len(), 3);
}
#[test]
fn deleting_a_range_returns_the_removed_text() {
let mut fixture = Fixture::new("hello world");
let removed = fixture.edit().delete_range(Range { start: 0, end: 6 });
assert_eq!(removed, "hello ");
assert_eq!(fixture.text(), "world");
assert_eq!(fixture.head(), Position::ZERO);
}
#[test]
fn a_line_wise_paste_lands_on_its_own_line_below() {
let mut fixture = Fixture::new("first\nsecond");
fixture.edit().paste("copied\n", true);
assert_eq!(fixture.text(), "first\ncopied\nsecond");
assert_eq!(fixture.head(), Position::new(1, 0));
}
#[test]
fn a_line_wise_paste_on_the_last_line_appends_a_new_line() {
let mut fixture = Fixture::new("only");
fixture.edit().paste("copied\n", true);
assert_eq!(fixture.text(), "only\ncopied");
assert_eq!(fixture.head(), Position::new(1, 0));
}
#[test]
fn a_fragment_paste_splices_into_the_current_line() {
let mut fixture = Fixture::new("ac");
fixture.caret_to(0, 1);
fixture.edit().paste("b", false);
assert_eq!(fixture.text(), "abc");
}
#[test]
fn undo_and_redo_round_trip_an_edit() {
let mut fixture = Fixture::new("start");
fixture.caret_to(0, 5);
fixture.edit().insert_text("!");
assert!(fixture.edit().undo());
assert_eq!(fixture.text(), "start");
assert!(fixture.edit().redo());
assert_eq!(fixture.text(), "start!");
assert!(!fixture.edit().redo());
}
#[test]
fn two_windows_on_one_buffer_edit_the_same_text() {
let mut buffer = Buffer::new(Document::from_text("shared", None));
let mut first = Window::new(0);
let mut second = Window::new(0);
Edit::new(&mut buffer, &mut first).insert_text(">");
Edit::new(&mut buffer, &mut second).insert_text("<");
assert_eq!(buffer.document.text().to_string(), "<>shared");
assert_eq!(first.cursor().head, Position::new(0, 1));
assert_eq!(second.cursor().head, Position::new(0, 1));
}
#[test]
fn one_history_serves_every_window_on_a_buffer() {
let mut buffer = Buffer::new(Document::from_text("", None));
let mut first = Window::new(0);
let mut second = Window::new(0);
Edit::new(&mut buffer, &mut first).insert_text("typed");
assert!(Edit::new(&mut buffer, &mut second).undo());
assert_eq!(buffer.document.text().to_string(), "");
}
}