aboutsummaryrefslogtreecommitdiff
path: root/src/buffer/mod.rs
diff options
context:
space:
mode:
authorrottedfm <rottedfm@proton.me>2026-08-19 11:24:55 -0400
committerrottedfm <rottedfm@proton.me>2026-08-19 11:24:55 -0400
commit8e16347b0eb329e84892af8ece36886324c95f62 (patch)
treebe1267972b5de2f1ae592577dfabce67f1fe6e87 /src/buffer/mod.rs
parentc6ae4660d1cc2414b22c492c5e819d009c8187c2 (diff)
parentea0bd36167b684c0accdb5ce2b2e21b8d84aeb25 (diff)
Merge branch 'buffer-implementation'HEADmain
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.rs615
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)
+}