//! The buffer core: document model, motions, viewport, and input handling. //! //! Deliberately free of `ratatui` so it can be exercised by requirements-based //! tests without a terminal; the rendering half lives in //! [`crate::components::buffer`]. //! //! Developed to DO-178C DAL-C. Items implementing a low-level requirement carry //! a `MJB-LLR-nnn` comment; see `docs/requirements/llr.md`. pub mod command; pub mod document; pub mod encoding; pub mod grapheme; pub mod history; pub mod keymap; pub mod line_ending; pub mod movement; pub mod save; pub mod selection; pub mod transaction; pub mod view; use std::path::PathBuf; use crossterm::event::KeyEvent; use ropey::{LineType, RopeSlice}; use self::{ command::Command, document::{Document, DocumentError}, keymap::{Keymap, KeymapResult, self_insert_char}, movement::{WordTarget, word_move}, selection::{Range, Selection}, transaction::Transaction, view::View, }; use crate::config::{Config, Mode}; /// The line-break convention. `LF_CR` is what ropey enables by default, and /// recognises LF, CR and CRLF — matching the endings [`line_ending`] detects. pub const LINE_TYPE: LineType = LineType::LF_CR; /// What the caller should do after a key was handled. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Outcome { /// Handled internally; nothing for the application to do. Consumed, /// The user asked to quit. Quit, /// The user asked to suspend. Suspend, } /// The editor state: one document, one viewport, one mode. pub struct Buffer { pub document: Document, pub view: View, pub mode: Mode, keymap: Keymap, config: Config, /// Command-line contents while in [`Mode::Command`]. pub command_line: String, /// Transient message shown on the status line. pub status: Option, /// Viewport height last rendered, needed by paging commands. pub last_height: usize, pub last_width: usize, } impl Buffer { pub fn new(config: Config, path: Option) -> Result { // MJB-LLR-111, MJB-LLR-112 let document = match path { Some(p) => Document::open(&p)?, None => Document::empty(None), }; let keymap = Keymap::new(&config.keybindings); Ok(Self { document, view: View::new(), mode: Mode::Normal, keymap, config, command_line: String::new(), status: None, last_height: 0, last_width: 0, }) } pub fn config(&self) -> &Config { &self.config } /// Pending keys, for the status line. pub fn pending_keys(&self) -> &[KeyEvent] { self.keymap.pending() } /// Route one key according to the current mode. pub fn handle_key(&mut self, key: KeyEvent) -> Outcome { self.status = None; // MJB-LLR-158: command mode is a line editor, not a keymap consumer. // Its three bindings still resolve so Esc/Enter/Backspace stay // configurable, but anything else types into the line. if self.mode == Mode::Command { return self.handle_command_mode_key(key); } match self.keymap.resolve(self.mode, key) { KeymapResult::Pending => Outcome::Consumed, KeymapResult::Matched(cmd, count) => self.execute(cmd, count.unwrap_or(1)), KeymapResult::Cancelled(keys) => { // MJB-LLR-156: insert mode types the character; every other // mode discards it. if self.mode == Mode::Insert && let Some(c) = self_insert_char(&keys) { self.insert_char(c); } Outcome::Consumed } } } fn handle_command_mode_key(&mut self, key: KeyEvent) -> Outcome { use crossterm::event::{KeyCode, KeyModifiers}; match self.keymap.resolve(self.mode, key) { KeymapResult::Matched(Command::NormalMode, _) => { self.command_line.clear(); self.set_mode(Mode::Normal); Outcome::Consumed } KeymapResult::Matched(Command::CommandSubmit, _) => { let line = std::mem::take(&mut self.command_line); self.set_mode(Mode::Normal); self.run_command_line(&line) } KeymapResult::Matched(Command::CommandBackspace, _) => { if self.command_line.pop().is_none() { // Backspacing an empty line leaves command mode, as Helix does. self.set_mode(Mode::Normal); } Outcome::Consumed } _ => { if let KeyCode::Char(c) = key.code && !key.modifiers.contains(KeyModifiers::CONTROL) && !key.modifiers.contains(KeyModifiers::ALT) { self.command_line.push(c); } Outcome::Consumed } } } /// MJB-LLR-159, MJB-LLR-160: parse and run a command line. pub fn run_command_line(&mut self, line: &str) -> Outcome { let line = line.trim(); let (name, _arg) = match line.split_once(char::is_whitespace) { Some((n, a)) => (n, Some(a.trim())), None => (line, None), }; let insert_final_newline = self.config.editor.insert_final_newline; match name { "" => Outcome::Consumed, // MJB-LLR-159 "w" | "write" => { self.save(false, insert_final_newline); Outcome::Consumed } "w!" | "write!" => { self.save(true, insert_final_newline); Outcome::Consumed } // MJB-LLR-160 "q" | "quit" => { if self.document.is_modified() { self.status = Some("unsaved changes (use :q! to discard, :wq to save)".to_owned()); Outcome::Consumed } else { Outcome::Quit } } "q!" | "quit!" => Outcome::Quit, "wq" | "x" | "write-quit" => { if self.save(false, insert_final_newline) { Outcome::Quit } else { Outcome::Consumed } } other => { // MJB-LLR-159: report, do not terminate. self.status = Some(format!("unknown command: {other}")); Outcome::Consumed } } } /// Returns whether the write succeeded. fn save(&mut self, force: bool, insert_final_newline: bool) -> bool { match self.document.save(force, insert_final_newline) { Ok(()) => { let name = self .document .path() .map(|p| p.display().to_string()) .unwrap_or_else(|| "[no name]".to_owned()); self.status = Some(format!("wrote {name}")); true } Err(e) => { self.status = Some(e.to_string()); false } } } fn set_mode(&mut self, mode: Mode) { if self.mode != mode { self.mode = mode; // A half-typed sequence must not survive a mode change. self.keymap.reset(); } } fn insert_char(&mut self, c: char) { let mut s = [0u8; 4]; self.insert_text(c.encode_utf8(&mut s)); } fn insert_text(&mut self, text: &str) { let t = Transaction::insert(self.document.text(), self.document.selection(), text); let at = self.document.range().cursor(self.document.slice()); if self.apply(&t) { // Cursor advances past what was inserted. self.document.set_range(Range::point(at + text.len())); } } fn apply(&mut self, t: &Transaction) -> bool { match self.document.apply(t) { Ok(()) => true, Err(e) => { self.status = Some(e.to_string()); false } } } /// Execute one command. `count` is at least 1. pub fn execute(&mut self, cmd: Command, count: usize) -> Outcome { use Command::*; let count = count.max(1); let extend = self.mode == Mode::Select; match cmd { Quit => return Outcome::Quit, Suspend => return Outcome::Suspend, // --- Modes --- NormalMode => self.set_mode(Mode::Normal), InsertMode => { // MJB-LLR-009: `i` inserts before the selection. let from = self.document.range().from(); self.document.set_range(Range::point(from)); self.set_mode(Mode::Insert); } SelectMode => { self.set_mode(if self.mode == Mode::Select { Mode::Normal } else { Mode::Select }); } CommandMode => { self.command_line.clear(); self.set_mode(Mode::Command); } // --- Motion (MJB-HLR-006) --- MoveCharLeft => self.motion(extend, |t, r| movement::move_char_left(t, r, count)), MoveCharRight => self.motion(extend, |t, r| movement::move_char_right(t, r, count)), MoveLineUp => self.motion(extend, |t, r| movement::move_vertically(t, r, count, false)), MoveLineDown => self.motion(extend, |t, r| movement::move_vertically(t, r, count, true)), ExtendCharLeft => self.motion(true, |t, r| movement::move_char_left(t, r, count)), ExtendCharRight => self.motion(true, |t, r| movement::move_char_right(t, r, count)), ExtendLineUp => self.motion(true, |t, r| movement::move_vertically(t, r, count, false)), ExtendLineDown => self.motion(true, |t, r| movement::move_vertically(t, r, count, true)), // --- Word motion; these leave selections (MJB-HLR-007) --- MoveNextWordStart => self.word(count, WordTarget::NextStart, false), MovePrevWordStart => self.word(count, WordTarget::PrevStart, false), MoveNextWordEnd => self.word(count, WordTarget::NextEnd, false), MoveNextLongWordStart => self.word(count, WordTarget::NextStart, true), MovePrevLongWordStart => self.word(count, WordTarget::PrevStart, true), MoveNextLongWordEnd => self.word(count, WordTarget::NextEnd, true), ExtendNextWordStart => self.word(count, WordTarget::NextStart, false), ExtendPrevWordStart => self.word(count, WordTarget::PrevStart, false), ExtendNextWordEnd => self.word(count, WordTarget::NextEnd, false), // --- Goto (MJB-HLR-008) --- GotoFileStart => { let r = movement::goto_file_start(self.document.slice()); self.put(r, extend); } GotoLastLine => { let r = movement::goto_last_line(self.document.slice()); self.put(r, extend); } GotoLineStart => { let r = movement::goto_line_start(self.document.slice(), self.document.range()); self.put(r, extend); } GotoLineEnd => { let r = movement::goto_line_end(self.document.slice(), self.document.range()); self.put(r, extend); } GotoFirstNonWhitespace => { let r = movement::goto_first_non_whitespace(self.document.slice(), self.document.range()); self.put(r, extend); } // --- Selection manipulation --- ExtendLineBelow => self.extend_line_below(count), CollapseSelection => { let cursor = self.document.range().cursor(self.document.slice()); self.document.set_range(Range::point(cursor)); } FlipSelections => { let r = self.document.range().flipped(); self.document.set_range(r); } SelectAll => { let len = self.document.text().len(); self.document.set_range(Range::new(0, len)); } // --- Entering insert mode (MJB-HLR-009) --- AppendMode => { // `a` inserts after the selection. In Helix a bare cursor is a // one-grapheme range, so appending lands *past* the grapheme // under it; our empty range has to step forward explicitly to // reproduce that. let text = self.document.slice(); let r = self.document.range(); let at = if r.is_empty() { grapheme::next_grapheme_boundary(text, r.cursor(text)) } else { r.to() }; self.document.set_range(Range::point(at)); self.set_mode(Mode::Insert); } InsertAtLineStart => { let r = movement::goto_first_non_whitespace(self.document.slice(), self.document.range()); self.document.set_range(Range::point(r.head)); self.set_mode(Mode::Insert); } InsertAtLineEnd => { let r = movement::goto_line_end(self.document.slice(), self.document.range()); self.document.set_range(Range::point(r.head)); self.set_mode(Mode::Insert); } OpenBelow => self.open_line(false), OpenAbove => self.open_line(true), // --- Modification (MJB-HLR-010) --- DeleteSelection => self.delete_selection(), ChangeSelection => { self.delete_selection(); self.set_mode(Mode::Insert); } InsertNewline => self.insert_text("\n"), InsertTab => self.insert_text("\t"), DeleteCharBackward => self.delete_char_backward(), DeleteCharForward => self.delete_char_forward(), DeleteWordBackward => self.delete_word_backward(), KillToLineStart => self.kill_to_line_start(), // --- Undo / redo (MJB-HLR-011) --- Undo => match self.document.undo() { Ok(false) => self.status = Some("already at oldest change".to_owned()), Ok(true) => {} Err(e) => self.status = Some(e.to_string()), }, Redo => match self.document.redo() { Ok(false) => self.status = Some("already at newest change".to_owned()), Ok(true) => {} Err(e) => self.status = Some(e.to_string()), }, // --- Paging (MJB-HLR-013) --- PageCursorHalfUp => self.page(self.last_height / 2, false), PageCursorHalfDown => self.page(self.last_height / 2, true), PageUp => self.page(self.last_height, false), PageDown => self.page(self.last_height, true), // Handled by handle_command_mode_key; unreachable elsewhere but // must not panic if a user binds them outside command mode. CommandSubmit | CommandBackspace => {} } Outcome::Consumed } // --- helpers --- /// Run a motion and install its result, extending the selection or /// collapsing to a point per `extend`. /// /// Takes a closure rather than a function pointer so the direction and /// count stay visible at the call site, instead of hiding behind a family /// of near-identical adapter functions. fn motion(&mut self, extend: bool, f: impl FnOnce(RopeSlice, Range) -> Range) { let text = self.document.slice(); let r = f(text, self.document.range()); self.put(r, extend); } fn put(&mut self, target: Range, extend: bool) { let text = self.document.slice(); let current = self.document.range(); let r = current.put_cursor(text, target.cursor(text), extend); self.document.set_range(r); } /// MJB-LLR-065..067: word motions install the returned range directly, /// because the range *is* the result — collapsing it would destroy the /// selection-first behaviour that makes `wd` work. fn word(&mut self, count: usize, target: WordTarget, long: bool) { let text = self.document.slice(); let r = word_move(text, self.document.range(), count, target, long); self.document.set_range(r); } /// Helix's `x`: select the current line; repeated, extend by one more. fn extend_line_below(&mut self, count: usize) { let text = self.document.slice(); let r = self.document.range(); let (start_line, end_line) = r.line_range(text); let already_whole_line = r.from() == text.line_to_byte_idx(start_line, LINE_TYPE) && r.to() == line_start_of_next(text, end_line); let (first, last) = if already_whole_line { (start_line, (end_line + count).min(last_line_index(text))) } else { (start_line, (end_line + count - 1).min(last_line_index(text))) }; let from = text.line_to_byte_idx(first, LINE_TYPE); let to = line_start_of_next(text, last); self.document.set_range(Range::new(from, to)); } fn open_line(&mut self, above: bool) { let text = self.document.slice(); let line = self.document.range().cursor_line(text); // `above` inserts the terminator at the line's start, so the blank line // appears *at* that offset. `below` inserts it after the line's content // — deliberately at the content end rather than at the next line's // start, because a final line with no trailing newline has no next line // to anchor to, and the blank line then lands one byte later. let (at, cursor) = if above { let start = text.line_to_byte_idx(line, LINE_TYPE); (start, start) } else { let eol = movement::line_end_byte(text, line); (eol, eol + 1) }; let t = Transaction::change(self.document.text(), [(at, at, Some("\n".to_owned()))]); if self.apply(&t) { self.document.set_range(Range::point(cursor)); self.set_mode(Mode::Insert); } } fn delete_selection(&mut self) { let r = self.document.range(); if r.is_empty() { // MJB-LLR-050 robustness: `d` with nothing selected deletes the // grapheme under the cursor rather than doing nothing. let text = self.document.slice(); let to = grapheme::next_grapheme_boundary(text, r.cursor(text)); if to == r.from() { return; } let t = Transaction::change(self.document.text(), [(r.from(), to, None)]); let from = r.from(); if self.apply(&t) { self.document.set_range(Range::point(from)); } return; } let from = r.from(); let t = Transaction::delete(self.document.text(), self.document.selection()); if self.apply(&t) { self.document.set_range(Range::point(from)); } } fn delete_char_backward(&mut self) { let text = self.document.slice(); let cursor = self.document.range().cursor(text); let from = grapheme::prev_grapheme_boundary(text, cursor); if from == cursor { return; // at the start of the buffer } let t = Transaction::change(self.document.text(), [(from, cursor, None)]); if self.apply(&t) { self.document.set_range(Range::point(from)); } } fn delete_char_forward(&mut self) { let text = self.document.slice(); let cursor = self.document.range().cursor(text); let to = grapheme::next_grapheme_boundary(text, cursor); if to == cursor { return; // at the end of the buffer } let t = Transaction::change(self.document.text(), [(cursor, to, None)]); if self.apply(&t) { self.document.set_range(Range::point(cursor)); } } fn delete_word_backward(&mut self) { let text = self.document.slice(); let cursor = self.document.range().cursor(text); if cursor == 0 { return; } let target = word_move(text, Range::point(cursor), 1, WordTarget::PrevStart, false); let from = target.from(); if from >= cursor { return; } let t = Transaction::change(self.document.text(), [(from, cursor, None)]); if self.apply(&t) { self.document.set_range(Range::point(from)); } } fn kill_to_line_start(&mut self) { let text = self.document.slice(); let cursor = self.document.range().cursor(text); let line = text.byte_to_line_idx(cursor, LINE_TYPE); let from = text.line_to_byte_idx(line, LINE_TYPE); if from >= cursor { return; } let t = Transaction::change(self.document.text(), [(from, cursor, None)]); if self.apply(&t) { self.document.set_range(Range::point(from)); } } fn page(&mut self, lines: usize, down: bool) { if lines == 0 { return; } let text = self.document.slice(); let r = self.view.page(text, self.document.range(), lines, down); self.document.set_range(r); } /// Re-anchor the viewport for a viewport of `height` rows. pub fn update_view(&mut self, width: usize, height: usize) { self.last_width = width; self.last_height = height; let text = self.document.slice(); let range = self.document.range(); self.view .ensure_cursor_in_view(text, range, height, self.config.editor.scrolloff); self.view.ensure_horizontal_in_view(text, range, width); } /// Replace the whole selection state — used by tests and by `%`. pub fn set_selection(&mut self, selection: Selection) { self.document.set_selection(selection); } } fn line_start_of_next(text: RopeSlice, line: usize) -> usize { let total = text.len_lines(LINE_TYPE); if line + 1 < total { text.line_to_byte_idx(line + 1, LINE_TYPE) } else { text.len() } } fn last_line_index(text: RopeSlice) -> usize { text.len_lines(LINE_TYPE).saturating_sub(1) }