diff options
| author | rottedfm <rottedfm@proton.me> | 2026-08-19 11:24:55 -0400 |
|---|---|---|
| committer | rottedfm <rottedfm@proton.me> | 2026-08-19 11:24:55 -0400 |
| commit | 8e16347b0eb329e84892af8ece36886324c95f62 (patch) | |
| tree | be1267972b5de2f1ae592577dfabce67f1fe6e87 /src/buffer/mod.rs | |
| parent | c6ae4660d1cc2414b22c492c5e819d009c8187c2 (diff) | |
| parent | ea0bd36167b684c0accdb5ce2b2e21b8d84aeb25 (diff) | |
Establishes the first working baseline: moji <file> opens a file into a
ropey rope and edits it with Helix selection-first modal editing, under a
DO-178C DAL-C requirements and traceability process.
Prior to this, main tracked four files and src/main.rs was still
println!("Hello, world!") — there was no buildable state to build on.
Verified on a fresh clone of the branch with no untracked files:
cargo build; clippy --all-targets -D warnings clean; 294 tests passing;
scripts/check-trace.sh reports 98/98 requirements traced in both
directions.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'src/buffer/mod.rs')
| -rw-r--r-- | src/buffer/mod.rs | 615 |
1 files changed, 615 insertions, 0 deletions
diff --git a/src/buffer/mod.rs b/src/buffer/mod.rs new file mode 100644 index 0000000..6924423 --- /dev/null +++ b/src/buffer/mod.rs @@ -0,0 +1,615 @@ +//! 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<String>, + /// 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<PathBuf>) -> Result<Self, DocumentError> { + // 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) +} |
