diff options
| author | rottedfm <rottedfm@proton.me> | 2026-08-19 11:21:47 -0400 |
|---|---|---|
| committer | rottedfm <rottedfm@proton.me> | 2026-08-19 11:21:47 -0400 |
| commit | ea0bd36167b684c0accdb5ce2b2e21b8d84aeb25 (patch) | |
| tree | be1267972b5de2f1ae592577dfabce67f1fe6e87 /src | |
| parent | 8c0b4c53b130555f040884c1f52b90f16b23e241 (diff) | |
feat: implement Helix-style modal buffer under DO-178C DAL-C
The repository was an unmodified ratatui component template: no editor
code, JSON5 config, and placeholder widgets. This establishes the first
working baseline — `moji <file>` opens a file into a ropey rope and edits
it with Helix selection-first semantics.
Requirements, implementation and tests land together because they must:
the traceability check rejects requirements with no implementation and
tests naming requirements that do not exist, so neither half is a valid
commit on its own.
Package renamed to mojibake-editor (mojibake was taken on crates.io);
binary is moji, library target stays mojibake.
Class: New behaviour
Requirements: MJB-HLR-001..019, MJB-LLR-001..205
Derived: MJB-DR-001..007 (DR-001 resolved, six open for review)
Verified: cargo build; clippy --all-targets -D warnings clean;
cargo test 294 passing; ./scripts/check-trace.sh 98/98/98;
cargo package clean
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'src')
| -rw-r--r-- | src/action.rs | 17 | ||||
| -rw-r--r-- | src/app.rs | 176 | ||||
| -rw-r--r-- | src/buffer/command.rs | 147 | ||||
| -rw-r--r-- | src/buffer/document.rs | 525 | ||||
| -rw-r--r-- | src/buffer/encoding.rs | 272 | ||||
| -rw-r--r-- | src/buffer/grapheme.rs | 325 | ||||
| -rw-r--r-- | src/buffer/history.rs | 228 | ||||
| -rw-r--r-- | src/buffer/keymap.rs | 392 | ||||
| -rw-r--r-- | src/buffer/line_ending.rs | 144 | ||||
| -rw-r--r-- | src/buffer/mod.rs | 615 | ||||
| -rw-r--r-- | src/buffer/movement.rs | 592 | ||||
| -rw-r--r-- | src/buffer/save.rs | 387 | ||||
| -rw-r--r-- | src/buffer/selection.rs | 344 | ||||
| -rw-r--r-- | src/buffer/transaction.rs | 424 | ||||
| -rw-r--r-- | src/buffer/view.rs | 371 | ||||
| -rw-r--r-- | src/cli.rs | 54 | ||||
| -rw-r--r-- | src/components.rs | 125 | ||||
| -rw-r--r-- | src/components/buffer.rs | 237 | ||||
| -rw-r--r-- | src/config.rs | 888 | ||||
| -rw-r--r-- | src/errors.rs | 77 | ||||
| -rw-r--r-- | src/lib.rs | 18 | ||||
| -rw-r--r-- | src/logging.rs | 36 | ||||
| -rw-r--r-- | src/main.rs | 15 | ||||
| -rw-r--r-- | src/tui.rs | 233 |
24 files changed, 6640 insertions, 2 deletions
diff --git a/src/action.rs b/src/action.rs new file mode 100644 index 0000000..29ee079 --- /dev/null +++ b/src/action.rs @@ -0,0 +1,17 @@ +use serde::{Deserialize, Serialize}; +use strum::Display; + +/// Application-level events, distinct from [`crate::buffer::command::Command`], +/// which is what key bindings name. `Action` is what the event loop routes; +/// `Command` is what the editor executes. +#[derive(Debug, Clone, PartialEq, Eq, Display, Serialize, Deserialize)] +pub enum Action { + Tick, + Render, + Resize(u16, u16), + Suspend, + Resume, + Quit, + ClearScreen, + Error(String), +} diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..063a30a --- /dev/null +++ b/src/app.rs @@ -0,0 +1,176 @@ +use std::path::PathBuf; + +use crossterm::event::KeyEvent; +use ratatui::prelude::Rect; +use tokio::sync::mpsc; +use tracing::{debug, info}; + +use crate::{ + action::Action, + buffer::{command::Command, keymap::Keymap}, + components::{Component, buffer::BufferComponent}, + config::{Config, Mode}, + tui::{Event, Tui}, +}; + +pub struct App { + config: Config, + tick_rate: f64, + frame_rate: f64, + components: Vec<Box<dyn Component>>, + should_quit: bool, + should_suspend: bool, + /// MJB-LLR-203: resolves only the `Global` scope. Every other mode belongs + /// to the buffer — see MJB-DR-004 for why ownership is split. + global_keymap: Keymap, + action_tx: mpsc::UnboundedSender<Action>, + action_rx: mpsc::UnboundedReceiver<Action>, +} + +impl App { + pub fn new(tick_rate: f64, frame_rate: f64, file: Option<PathBuf>) -> color_eyre::Result<Self> { + let (action_tx, action_rx) = mpsc::unbounded_channel(); + let config = Config::new()?; + let global_keymap = Keymap::new(&config.keybindings); + + // MJB-LLR-204: the buffer is the only component. The template's + // FpsCounter and Home widgets are gone, not merely unregistered. + let buffer = BufferComponent::new(config.clone(), file)?; + + Ok(Self { + tick_rate, + frame_rate, + components: vec![Box::new(buffer)], + should_quit: false, + should_suspend: false, + config, + global_keymap, + action_tx, + action_rx, + }) + } + + pub async fn run(&mut self) -> color_eyre::Result<()> { + let mut tui = Tui::new()? + .tick_rate(self.tick_rate) + .frame_rate(self.frame_rate); + tui.enter()?; + + for component in self.components.iter_mut() { + component.register_action_handler(self.action_tx.clone())?; + } + for component in self.components.iter_mut() { + component.register_config_handler(self.config.clone())?; + } + for component in self.components.iter_mut() { + component.init(tui.size()?)?; + } + + let action_tx = self.action_tx.clone(); + loop { + self.handle_events(&mut tui).await?; + self.handle_actions(&mut tui)?; + if self.should_suspend { + tui.suspend()?; + action_tx.send(Action::Resume)?; + action_tx.send(Action::ClearScreen)?; + tui.enter()?; + } else if self.should_quit { + tui.stop()?; + break; + } + } + tui.exit()?; + Ok(()) + } + + async fn handle_events(&mut self, tui: &mut Tui) -> color_eyre::Result<()> { + let Some(event) = tui.next_event().await else { + return Ok(()); + }; + let action_tx = self.action_tx.clone(); + + match event { + Event::Quit => action_tx.send(Action::Quit)?, + Event::Tick => action_tx.send(Action::Tick)?, + Event::Render => action_tx.send(Action::Render)?, + Event::Resize(x, y) => action_tx.send(Action::Resize(x, y))?, + // MJB-LLR-203: a global binding consumes the key outright. The + // template forwarded every key both here and to every component, + // which would fire `Quit` while typing in insert mode. + Event::Key(key) if self.handle_global_key(key)? => return Ok(()), + _ => {} + } + + for component in self.components.iter_mut() { + if let Some(action) = component.handle_events(Some(event.clone()))? { + action_tx.send(action)?; + } + } + Ok(()) + } + + /// Returns whether the key was consumed by the global keymap. + fn handle_global_key(&mut self, key: KeyEvent) -> color_eyre::Result<bool> { + let Some(command) = self.global_keymap.lookup_single(Mode::Global, key) else { + return Ok(false); + }; + + let action = match command { + Command::Quit => Action::Quit, + Command::Suspend => Action::Suspend, + // Anything else bound globally is not an application concern; let + // the buffer handle it in its own mode. + _ => return Ok(false), + }; + + info!("Global binding matched: {command}"); + self.action_tx.send(action)?; + Ok(true) + } + + fn handle_actions(&mut self, tui: &mut Tui) -> color_eyre::Result<()> { + while let Ok(action) = self.action_rx.try_recv() { + if action != Action::Tick && action != Action::Render { + debug!("{action:?}"); + } + match action { + // MJB-LLR-205: `Tick` no longer drains a pending-key buffer. + // Chord resolution is time-independent (MJB-LLR-154). + Action::Quit => self.should_quit = true, + Action::Suspend => self.should_suspend = true, + Action::Resume => self.should_suspend = false, + Action::ClearScreen => tui.terminal.clear()?, + Action::Resize(w, h) => self.handle_resize(tui, w, h)?, + Action::Render => self.render(tui)?, + Action::Error(ref err) => tracing::error!(?err), + _ => {} + } + for component in self.components.iter_mut() { + if let Some(action) = component.update(action.clone())? { + self.action_tx.send(action)? + }; + } + } + Ok(()) + } + + fn handle_resize(&mut self, tui: &mut Tui, w: u16, h: u16) -> color_eyre::Result<()> { + tui.resize(Rect::new(0, 0, w, h))?; + self.render(tui)?; + Ok(()) + } + + fn render(&mut self, tui: &mut Tui) -> color_eyre::Result<()> { + tui.draw(|frame| { + for component in self.components.iter_mut() { + if let Err(err) = component.draw(frame, frame.area()) { + let _ = self + .action_tx + .send(Action::Error(format!("Failed to draw: {:?}", err))); + } + } + })?; + Ok(()) + } +} diff --git a/src/buffer/command.rs b/src/buffer/command.rs new file mode 100644 index 0000000..c5a2aef --- /dev/null +++ b/src/buffer/command.rs @@ -0,0 +1,147 @@ +//! Editor commands — the values key bindings map to. +//! +//! Kept distinct from [`crate::action::Action`], which is application-level +//! (tick, render, resize). A binding names a `Command`; the buffer executes it. +//! `Quit` and `Suspend` appear here because the `Global` keymap is expressed in +//! the same table and must be able to name them. + +use serde::{Deserialize, Serialize}; +use strum::Display; + +/// MJB-LLR-157: one unit variant per bound editor command, deserialized from +/// the variant name so a TOML value like `"MoveCharLeft"` resolves directly. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Display, Serialize, Deserialize)] +pub enum Command { + // --- Application (reachable from the `global` keymap) --- + Quit, + Suspend, + + // --- Mode switching --- + NormalMode, + InsertMode, + SelectMode, + CommandMode, + + // --- Character and line motion (MJB-HLR-006) --- + MoveCharLeft, + MoveCharRight, + MoveLineUp, + MoveLineDown, + + // --- Word motion; these produce selections (MJB-HLR-007) --- + MoveNextWordStart, + MovePrevWordStart, + MoveNextWordEnd, + MoveNextLongWordStart, + MovePrevLongWordStart, + MoveNextLongWordEnd, + + // --- Extending variants, used by select mode --- + ExtendCharLeft, + ExtendCharRight, + ExtendLineUp, + ExtendLineDown, + ExtendNextWordStart, + ExtendPrevWordStart, + ExtendNextWordEnd, + + // --- Goto (MJB-HLR-008) --- + GotoFileStart, + GotoLastLine, + GotoLineStart, + GotoLineEnd, + GotoFirstNonWhitespace, + + // --- Selection manipulation --- + ExtendLineBelow, + CollapseSelection, + FlipSelections, + SelectAll, + + // --- Entering insert mode (MJB-HLR-009) --- + AppendMode, + InsertAtLineStart, + InsertAtLineEnd, + OpenBelow, + OpenAbove, + + // --- Modification (MJB-HLR-010) --- + DeleteSelection, + ChangeSelection, + InsertNewline, + InsertTab, + DeleteCharBackward, + DeleteCharForward, + DeleteWordBackward, + KillToLineStart, + + // --- Undo / redo (MJB-HLR-011) --- + Undo, + Redo, + + // --- Scrolling and paging (MJB-HLR-013) --- + PageCursorHalfUp, + PageCursorHalfDown, + PageUp, + PageDown, + + // --- Command line (MJB-HLR-016) --- + CommandSubmit, + CommandBackspace, +} + +#[cfg(test)] +mod tests { + use super::*; + + /// MJB-LLR-157: a TOML value naming a variant deserializes to it, which is + /// what makes the keymap config-driven. + #[test] + fn mjb_llr_157_deserializes_from_the_variant_name() { + let cmd: Command = serde_json_free_parse("MoveCharLeft"); + assert_eq!(cmd, Command::MoveCharLeft); + assert_eq!(serde_json_free_parse("Undo"), Command::Undo); + assert_eq!( + serde_json_free_parse("PageCursorHalfDown"), + Command::PageCursorHalfDown + ); + } + + #[test] + fn mjb_llr_157_unknown_command_name_is_an_error_not_a_panic() { + let err = toml::from_str::<Wrapper>("cmd = \"NoSuchCommand\"").unwrap_err(); + assert!( + err.to_string().contains("NoSuchCommand"), + "error must name the offending value, got: {err}" + ); + } + + #[test] + fn mjb_llr_157_display_round_trips_through_deserialization() { + for cmd in [ + Command::Quit, + Command::GotoFileStart, + Command::DeleteSelection, + Command::CommandSubmit, + ] { + assert_eq!( + serde_json_free_parse(&cmd.to_string()), + cmd, + "{cmd} must round-trip" + ); + } + } + + #[derive(Debug, serde::Deserialize)] + struct Wrapper { + cmd: Command, + } + + /// Parse a bare command name the way the keymap table does. + fn serde_json_free_parse(name: &str) -> Command { + let doc = format!("cmd = \"{name}\""); + toml::from_str::<Wrapper>(&doc) + .unwrap_or_else(|e| panic!("{name} must parse: {e}")) + .cmd + } +} diff --git a/src/buffer/document.rs b/src/buffer/document.rs new file mode 100644 index 0000000..13c02ba --- /dev/null +++ b/src/buffer/document.rs @@ -0,0 +1,525 @@ +//! The document: rope, associated path, encoding state, selection, history. +//! +//! **This is the only module that touches `ropey` types directly** as an owner. +//! Confining the dependency here is the mitigation recorded in MJB-DR-006 for +//! depending on a pre-release rope crate under DAL-C. + +use std::path::{Path, PathBuf}; + +use ropey::{Rope, RopeSlice}; + +use super::{ + LINE_TYPE, + encoding::{self, DecodeError, EncodingInfo}, + history::History, + line_ending::{self, LineEnding}, + save::{self, SaveError}, + selection::{Range, Selection}, + transaction::{ChangeError, Transaction}, +}; + +#[derive(Debug, thiserror::Error)] +pub enum DocumentError { + #[error("{0}")] + Change(#[from] ChangeError), + #[error("{0}")] + Save(#[from] SaveError), + // MJB-LLR-118: opening a file mojibake cannot decode is a reportable + // failure, not something to paper over. + #[error("{0}")] + Decode(#[from] DecodeError), + #[error("io error: {0}")] + Io(#[from] std::io::Error), +} + +#[derive(Debug)] +pub struct Document { + text: Rope, + path: Option<PathBuf>, + encoding: EncodingInfo, + line_ending: LineEnding, + selection: Selection, + history: History, + /// The history revision the file on disk corresponds to. + /// + /// `modified` is derived from this rather than latched to a bool, so + /// undoing back to the last-saved state correctly reports the buffer as + /// clean. A latched flag would keep claiming unsaved changes for a buffer + /// byte-identical to its file. (MJB-LLR-116) + saved_revision: usize, +} + +impl Default for Document { + fn default() -> Self { + Self::empty(None) + } +} + +impl Document { + pub fn empty(path: Option<PathBuf>) -> Self { + Self { + text: Rope::new(), + path, + encoding: EncodingInfo::default(), + line_ending: LineEnding::platform_default(), + selection: Selection::point(0), + history: History::new(), + saved_revision: 0, + } + } + + /// MJB-LLR-111, MJB-LLR-112: load `path`. + /// + /// A path that does not exist yields an empty buffer that remembers it, so + /// `:w` creates the file. Any other IO error is reported. + pub fn open(path: &Path) -> Result<Self, DocumentError> { + let bytes = match std::fs::read(path) { + Ok(b) => b, + // MJB-LLR-112 + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + return Ok(Self::empty(Some(path.to_path_buf()))); + } + Err(e) => return Err(DocumentError::Io(e)), + }; + + // MJB-LLR-113, MJB-LLR-118: a declared encoding is transcoded; invalid + // UTF-8 is rejected here rather than opened and later written back + // with the damage baked in. + let (text, encoding) = encoding::decode(&bytes)?; + // MJB-LLR-114 is evaluated on the raw text, before normalisation + // collapses CRLF and would erase the evidence. + let line_ending = LineEnding::detect(&text); + let normalized = line_ending::normalize(&text); + + Ok(Self { + text: Rope::from_str(&normalized), + path: Some(path.to_path_buf()), + encoding, + line_ending, + selection: Selection::point(0), + history: History::new(), + saved_revision: 0, + }) + } + + pub fn text(&self) -> &Rope { + &self.text + } + + pub fn slice(&self) -> RopeSlice<'_> { + self.text.slice(..) + } + + pub fn path(&self) -> Option<&Path> { + self.path.as_deref() + } + + pub fn set_path(&mut self, path: PathBuf) { + self.path = Some(path); + } + + pub fn selection(&self) -> &Selection { + &self.selection + } + + pub fn set_selection(&mut self, selection: Selection) { + self.selection = selection.clamped(self.text.slice(..)); + } + + /// Convenience: replace the primary range. + pub fn set_range(&mut self, range: Range) { + let clamped = range.clamped(self.text.slice(..)); + self.selection.set_primary(clamped); + } + + pub fn range(&self) -> Range { + self.selection.primary() + } + + /// MJB-LLR-116: whether the buffer differs from the file on disk. + /// + /// Derived by comparing the current history revision against the one the + /// file was written at, so undoing back to the saved state reports clean. + pub fn is_modified(&self) -> bool { + self.history.revision() != self.saved_revision + } + + pub fn line_ending(&self) -> LineEnding { + self.line_ending + } + + pub fn len_lines(&self) -> usize { + self.text.len_lines(LINE_TYPE) + } + + pub fn history_mut(&mut self) -> &mut History { + &mut self.history + } + + /// MJB-LLR-117: apply `transaction`, recording its inverse for undo. + pub fn apply(&mut self, transaction: &Transaction) -> Result<(), DocumentError> { + // The inverse must be computed against the pre-change rope. + let inverse = Transaction::new(transaction.changes.invert(&self.text)); + + transaction.changes.apply(&mut self.text)?; + + if let Some(sel) = &transaction.selection { + self.selection = sel.clone().clamped(self.text.slice(..)); + } else { + self.selection = self.selection.clone().clamped(self.text.slice(..)); + } + + self.history.commit(transaction.clone(), inverse); + Ok(()) + } + + /// Apply without recording history — used to replay an undo or redo, whose + /// own bookkeeping is already handled by [`History`]. + fn apply_without_history(&mut self, transaction: &Transaction) -> Result<(), DocumentError> { + transaction.changes.apply(&mut self.text)?; + if let Some(sel) = &transaction.selection { + self.selection = sel.clone().clamped(self.text.slice(..)); + } else { + self.selection = self.selection.clone().clamped(self.text.slice(..)); + } + Ok(()) + } + + /// MJB-LLR-052: revert the most recent change. `false` if there was none. + pub fn undo(&mut self) -> Result<bool, DocumentError> { + let Some(t) = self.history.undo().cloned() else { + return Ok(false); + }; + self.apply_without_history(&t)?; + Ok(true) + } + + /// MJB-LLR-053: reapply the most recently reverted change. + pub fn redo(&mut self) -> Result<bool, DocumentError> { + let Some(t) = self.history.redo().cloned() else { + return Ok(false); + }; + self.apply_without_history(&t)?; + Ok(true) + } + + /// MJB-LLR-115: the document as it should appear on disk. + /// + /// Assembles the text once. Chaining `to_string` → `with_final_newline` → + /// `apply` → `encode` would copy the whole document at each step; the + /// terminator rewrite and the final newline are folded into a single pass + /// so only the encode step copies, and only when the encoding is not the + /// UTF-8 the rope already holds. + pub fn encode(&self, insert_final_newline: bool) -> Vec<u8> { + let ending = self.line_ending.as_str(); + let needs_rewrite = self.line_ending != LineEnding::Lf; + + let mut text = String::with_capacity(self.text.len() + 1); + for chunk in self.text.chunks() { + if needs_rewrite { + // The rope stores LF only, so a plain split is sufficient. + let mut parts = chunk.split('\n'); + if let Some(first) = parts.next() { + text.push_str(first); + } + for part in parts { + text.push_str(ending); + text.push_str(part); + } + } else { + text.push_str(chunk); + } + } + + if insert_final_newline && !text.is_empty() && !text.ends_with(ending) { + text.push_str(ending); + } + + encoding::encode(&text, self.encoding) + } + + /// MJB-LLR-137: write to the associated path and mark it clean. + pub fn save(&mut self, force: bool, insert_final_newline: bool) -> Result<(), DocumentError> { + let path = self.path.clone().ok_or(SaveError::NoPath)?; + let bytes = self.encode(insert_final_newline); + save::write_atomic(&path, &bytes, force)?; + self.saved_revision = self.history.revision(); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::buffer::selection::Range; + + fn doc(text: &str) -> Document { + let mut d = Document::empty(None); + d.text = Rope::from_str(text); + d + } + + #[test] + fn mjb_llr_112_missing_file_yields_empty_buffer_that_remembers_the_path() { + let d = tempfile::tempdir().unwrap(); + let p = d.path().join("not-created-yet.txt"); + let doc = Document::open(&p).unwrap(); + assert_eq!(doc.text().len(), 0); + assert_eq!(doc.path(), Some(p.as_path())); + assert!(!doc.is_modified()); + } + + #[test] + fn mjb_llr_111_loads_contents() { + let d = tempfile::tempdir().unwrap(); + let p = d.path().join("f.txt"); + std::fs::write(&p, "hello\nworld\n").unwrap(); + let doc = Document::open(&p).unwrap(); + assert_eq!(doc.text().to_string(), "hello\nworld\n"); + } + + #[test] + fn mjb_llr_114_crlf_is_detected_and_normalized_for_storage() { + let d = tempfile::tempdir().unwrap(); + let p = d.path().join("crlf.txt"); + std::fs::write(&p, "a\r\nb\r\n").unwrap(); + + let doc = Document::open(&p).unwrap(); + assert_eq!(doc.line_ending(), LineEnding::Crlf); + assert_eq!(doc.text().to_string(), "a\nb\n", "stored as LF"); + } + + /// MJB-LLR-115, MJB-HLR-003: a CRLF file saved back is still CRLF. + #[test] + fn mjb_llr_115_crlf_round_trips_through_save() { + let d = tempfile::tempdir().unwrap(); + let p = d.path().join("crlf.txt"); + std::fs::write(&p, "a\r\nb\r\n").unwrap(); + + let mut doc = Document::open(&p).unwrap(); + doc.save(false, true).unwrap(); + assert_eq!(std::fs::read(&p).unwrap(), b"a\r\nb\r\n"); + } + + /// MJB-LLR-118: a file that is not valid UTF-8 is refused, and the file on + /// disk is left exactly as it was. + #[test] + fn mjb_llr_118_invalid_utf8_file_is_refused() { + let d = tempfile::tempdir().unwrap(); + let p = d.path().join("bad.bin"); + let original = [b'a', 0xFF, b'b']; + std::fs::write(&p, original).unwrap(); + + let err = Document::open(&p).expect_err("must refuse to open"); + assert!(matches!(err, DocumentError::Decode(_)), "got {err:?}"); + assert!( + err.to_string().contains("UTF-8"), + "message must explain why, got: {err}" + ); + assert_eq!( + std::fs::read(&p).unwrap(), + original, + "a refused open must not touch the file" + ); + } + + /// MJB-LLR-113: a BOM-declared non-UTF-8 file still opens. + #[test] + fn mjb_llr_113_declared_utf16_file_opens() { + let d = tempfile::tempdir().unwrap(); + let p = d.path().join("u16.txt"); + // UTF-16LE BOM + "hi". + std::fs::write(&p, [0xFF, 0xFE, b'h', 0x00, b'i', 0x00]).unwrap(); + + let doc = Document::open(&p).expect("a declared encoding must open"); + assert_eq!(doc.text().to_string(), "hi"); + } + + #[test] + fn mjb_llr_116_modified_flag_lifecycle() { + let d = tempfile::tempdir().unwrap(); + let p = d.path().join("f.txt"); + std::fs::write(&p, "abc").unwrap(); + + let mut doc = Document::open(&p).unwrap(); + assert!(!doc.is_modified(), "freshly opened"); + + let t = Transaction::insert(doc.text(), doc.selection(), "X"); + doc.apply(&t).unwrap(); + assert!(doc.is_modified(), "set by an applied transaction"); + + doc.save(false, false).unwrap(); + assert!(!doc.is_modified(), "cleared by a successful write"); + } + + /// MJB-LLR-137: a successful write clears `modified`; a failed one must + /// not, or the user would be told their unsaved work is safe. + #[test] + fn mjb_llr_137_failed_write_leaves_modified_set() { + let mut doc = doc("content"); + // No path: the save cannot succeed. + let t = Transaction::insert(doc.text(), doc.selection(), "X"); + doc.apply(&t).unwrap(); + assert!(doc.is_modified()); + + assert!(doc.save(false, true).is_err()); + assert!( + doc.is_modified(), + "a failed write must not clear the modified flag" + ); + } + + /// MJB-LLR-116: undoing back to the saved state reports the buffer clean. + /// A latched flag would keep claiming unsaved changes for content that is + /// byte-identical to the file. + #[test] + fn mjb_llr_116_undo_back_to_saved_state_is_clean() { + let d = tempfile::tempdir().unwrap(); + let p = d.path().join("f.txt"); + std::fs::write(&p, "abc").unwrap(); + let mut doc = Document::open(&p).unwrap(); + + doc.save(false, false).unwrap(); + assert!(!doc.is_modified()); + + let t = Transaction::insert(doc.text(), doc.selection(), "X"); + doc.apply(&t).unwrap(); + assert!(doc.is_modified(), "an edit dirties the buffer"); + + doc.undo().unwrap(); + assert!( + !doc.is_modified(), + "undoing back to the saved content must report clean" + ); + + doc.redo().unwrap(); + assert!(doc.is_modified(), "redoing dirties it again"); + } + + /// MJB-LLR-116: same history depth, different content, must stay dirty. + #[test] + fn mjb_llr_116_divergent_edit_at_the_same_depth_stays_modified() { + let d = tempfile::tempdir().unwrap(); + let p = d.path().join("f.txt"); + std::fs::write(&p, "abc").unwrap(); + let mut doc = Document::open(&p).unwrap(); + + let t = Transaction::insert(doc.text(), doc.selection(), "X"); + doc.apply(&t).unwrap(); + doc.save(false, false).unwrap(); + assert!(!doc.is_modified()); + + doc.undo().unwrap(); + // A *different* edit, returning to the same history depth. + let t = Transaction::insert(doc.text(), doc.selection(), "Y"); + doc.apply(&t).unwrap(); + + assert!( + doc.is_modified(), + "content differs from the file despite equal history depth" + ); + assert_eq!(doc.text().to_string(), "Yabc"); + } + + #[test] + fn mjb_llr_137_successful_write_clears_modified() { + let d = tempfile::tempdir().unwrap(); + let p = d.path().join("f.txt"); + let mut doc = Document::open(&p).unwrap(); + + let t = Transaction::insert(doc.text(), doc.selection(), "hello"); + doc.apply(&t).unwrap(); + assert!(doc.is_modified()); + + doc.save(false, true).unwrap(); + assert!(!doc.is_modified()); + assert_eq!(std::fs::read_to_string(&p).unwrap(), "hello\n"); + } + + #[test] + fn mjb_llr_117_apply_updates_text_and_records_history() { + let mut doc = doc("hello"); + let t = Transaction::change(doc.text(), [(0, 5, Some("goodbye".into()))]); + doc.apply(&t).unwrap(); + assert_eq!(doc.text().to_string(), "goodbye"); + assert!(doc.history.can_undo()); + } + + #[test] + fn mjb_llr_052_undo_restores_previous_text() { + let mut doc = doc("hello"); + let t = Transaction::change(doc.text(), [(0, 5, Some("goodbye".into()))]); + doc.apply(&t).unwrap(); + + assert!(doc.undo().unwrap()); + assert_eq!(doc.text().to_string(), "hello"); + } + + #[test] + fn mjb_llr_053_redo_reapplies() { + let mut doc = doc("hello"); + let t = Transaction::change(doc.text(), [(0, 5, Some("goodbye".into()))]); + doc.apply(&t).unwrap(); + doc.undo().unwrap(); + + assert!(doc.redo().unwrap()); + assert_eq!(doc.text().to_string(), "goodbye"); + } + + /// MJB-LLR-052: undoing past the start is a no-op, not an error. + #[test] + fn mjb_llr_052_undo_past_history_start_is_a_noop() { + let mut doc = doc("hello"); + assert!(!doc.undo().unwrap(), "nothing to undo"); + assert_eq!(doc.text().to_string(), "hello"); + assert!(!doc.undo().unwrap(), "still nothing, still safe"); + } + + #[test] + fn mjb_llr_053_redo_past_end_is_a_noop() { + let mut doc = doc("hello"); + assert!(!doc.redo().unwrap()); + assert_eq!(doc.text().to_string(), "hello"); + } + + #[test] + fn multiple_undo_redo_cycles_are_stable() { + let mut doc = doc(""); + for c in ["a", "b", "c"] { + let t = Transaction::insert(doc.text(), doc.selection(), c); + doc.apply(&t).unwrap(); + let end = doc.text().len(); + doc.set_range(Range::point(end)); + } + assert_eq!(doc.text().to_string(), "abc"); + + for _ in 0..3 { + assert!(doc.undo().unwrap()); + } + assert_eq!(doc.text().to_string(), ""); + + for _ in 0..3 { + assert!(doc.redo().unwrap()); + } + assert_eq!(doc.text().to_string(), "abc"); + } + + #[test] + fn mjb_llr_185_final_newline_added_on_encode_when_requested() { + let doc = doc("no trailing newline"); + assert!(doc.encode(true).ends_with(b"\n")); + assert!(!doc.encode(false).ends_with(b"\n")); + } + + #[test] + fn mjb_llr_185_empty_document_encodes_empty() { + let doc = doc(""); + assert!(doc.encode(true).is_empty(), "must not invent a newline"); + } + + #[test] + fn saving_without_a_path_is_an_error_not_a_panic() { + let mut doc = doc("x"); + assert!(doc.save(false, true).is_err()); + } +} diff --git a/src/buffer/encoding.rs b/src/buffer/encoding.rs new file mode 100644 index 0000000..5cd852f --- /dev/null +++ b/src/buffer/encoding.rs @@ -0,0 +1,272 @@ +//! Character encoding detection and transcoding (MJB-HLR-002). +//! +//! Two rules, the second an exception to the first: +//! +//! 1. **A file whose encoding a byte order mark declares is transcoded.** The +//! encoding is known, so its bytes round-trip through load and save. +//! 2. **UTF-8 is strict.** A file assumed or declared to be UTF-8 that holds an +//! invalid byte sequence is *rejected*, not repaired. +//! +//! Rule 2 exists because substitution is lossy in a way the user cannot see: +//! replacing a bad byte with U+FFFD and then saving writes the replacement +//! character over their data. For a declared encoding we can at least reproduce +//! what we read; for malformed UTF-8 we cannot, so refusing to open is the only +//! non-destructive answer. See MJB-DR-001. + +use encoding_rs::{Encoding, UTF_8, UTF_16BE, UTF_16LE}; + +/// MJB-LLR-118: why a file could not be decoded. +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum DecodeError { + #[error( + "not valid UTF-8 (invalid byte sequence at offset {valid_up_to}); \ + mojibake edits text, and repairing the bytes would destroy them on save" + )] + InvalidUtf8 { valid_up_to: usize }, +} + +/// The encoding a document was loaded with, plus whether it carried a BOM. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct EncodingInfo { + pub encoding: &'static Encoding, + pub has_bom: bool, +} + +impl Default for EncodingInfo { + fn default() -> Self { + Self { + encoding: UTF_8, + has_bom: false, + } + } +} + +/// MJB-LLR-110: recognise a byte order mark, returning the encoding it implies +/// and the mark's length in bytes. +pub fn detect_bom(bytes: &[u8]) -> Option<(&'static Encoding, usize)> { + if bytes.starts_with(&[0xEF, 0xBB, 0xBF]) { + Some((UTF_8, 3)) + } else if bytes.starts_with(&[0xFF, 0xFE]) { + Some((UTF_16LE, 2)) + } else if bytes.starts_with(&[0xFE, 0xFF]) { + Some((UTF_16BE, 2)) + } else { + None + } +} + +/// The byte order mark for `encoding`, if it has one. +pub fn bom_bytes(encoding: &'static Encoding) -> &'static [u8] { + if encoding == UTF_8 { + &[0xEF, 0xBB, 0xBF] + } else if encoding == UTF_16LE { + &[0xFF, 0xFE] + } else if encoding == UTF_16BE { + &[0xFE, 0xFF] + } else { + &[] + } +} + +/// MJB-LLR-111, MJB-LLR-113, MJB-LLR-118: decode `bytes` to a `String`. +/// +/// UTF-8 — whether declared by a BOM or merely assumed — is validated strictly +/// and rejected when malformed. Other BOM-declared encodings are transcoded. +pub fn decode(bytes: &[u8]) -> Result<(String, EncodingInfo), DecodeError> { + let (encoding, has_bom, body) = match detect_bom(bytes) { + Some((encoding, bom_len)) => (encoding, true, &bytes[bom_len..]), + None => (UTF_8, false, bytes), + }; + + let text = if encoding == UTF_8 { + // MJB-LLR-118: the exception. `from_utf8` reports exactly how far the + // input was valid, which makes the diagnostic actionable. + std::str::from_utf8(body) + .map_err(|e| DecodeError::InvalidUtf8 { + valid_up_to: e.valid_up_to(), + })? + .to_owned() + } else { + // MJB-LLR-113: a declared non-UTF-8 encoding is transcoded. Its bytes + // round-trip on save, so any substitution here is reproducible. + encoding.decode_without_bom_handling(body).0.into_owned() + }; + + Ok((text, EncodingInfo { encoding, has_bom })) +} + +/// MJB-LLR-115: encode `text` back to bytes, re-emitting the BOM when the +/// document was loaded with one. +/// +/// Total by construction: `text` is a `str`, and a document can only hold text +/// that [`decode`] accepted, so there is nothing here that can fail. +/// +/// UTF-16 is encoded here by hand rather than through `encoding_rs`. +/// `Encoding::encode` is deliberately asymmetric: it decodes UTF-16 but will +/// not *encode* to it, silently substituting UTF-8 instead. Delegating would +/// therefore write UTF-8 bytes beneath a UTF-16 BOM and corrupt the file. +/// See MJB-DR-007. +pub fn encode(text: &str, info: EncodingInfo) -> Vec<u8> { + let mut out = Vec::with_capacity(text.len() + 3); + if info.has_bom { + out.extend_from_slice(bom_bytes(info.encoding)); + } + + if info.encoding == UTF_16LE { + for unit in text.encode_utf16() { + out.extend_from_slice(&unit.to_le_bytes()); + } + } else if info.encoding == UTF_16BE { + for unit in text.encode_utf16() { + out.extend_from_slice(&unit.to_be_bytes()); + } + } else { + let (bytes, _, _) = info.encoding.encode(text); + out.extend_from_slice(&bytes); + } + + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn mjb_llr_110_detects_each_bom() { + assert_eq!(detect_bom(&[0xEF, 0xBB, 0xBF, b'a']), Some((UTF_8, 3))); + assert_eq!(detect_bom(&[0xFF, 0xFE, b'a', 0]), Some((UTF_16LE, 2))); + assert_eq!(detect_bom(&[0xFE, 0xFF, 0, b'a']), Some((UTF_16BE, 2))); + assert_eq!(detect_bom(b"plain"), None); + assert_eq!(detect_bom(b""), None, "empty input must not index past end"); + } + + #[test] + fn mjb_llr_111_plain_utf8_round_trips() { + let (text, info) = decode("hello 文字化け".as_bytes()).unwrap(); + assert_eq!(text, "hello 文字化け"); + assert!(!info.has_bom); + assert_eq!(encode(&text, info), "hello 文字化け".as_bytes()); + } + + /// MJB-LLR-115, MJB-DR-001: a BOM present on load reappears on save. + #[test] + fn mjb_llr_115_bom_round_trips() { + let mut input = vec![0xEF, 0xBB, 0xBF]; + input.extend_from_slice(b"hi"); + + let (text, info) = decode(&input).unwrap(); + assert_eq!(text, "hi", "BOM is stripped from the buffer contents"); + assert!(info.has_bom); + assert_eq!(encode(&text, info), input, "and re-emitted on write"); + } + + /// MJB-LLR-115, MJB-DR-007: UTF-16 must survive a load/save cycle. + /// + /// Regression guard: `encoding_rs::Encoding::encode` substitutes UTF-8 for + /// UTF-16 rather than failing, so delegating to it here would write UTF-8 + /// bytes under a UTF-16 BOM and corrupt the file. + #[test] + fn mjb_llr_115_utf16le_round_trips() { + // UTF-16LE BOM followed by "hi". + let input = vec![0xFF, 0xFE, b'h', 0x00, b'i', 0x00]; + let (text, info) = decode(&input).unwrap(); + assert_eq!(text, "hi"); + assert_eq!(info.encoding, UTF_16LE); + assert_eq!(encode(&text, info), input, "must not degrade to UTF-8"); + } + + #[test] + fn mjb_llr_115_utf16be_round_trips() { + let input = vec![0xFE, 0xFF, 0x00, b'h', 0x00, b'i']; + let (text, info) = decode(&input).unwrap(); + assert_eq!(text, "hi"); + assert_eq!(info.encoding, UTF_16BE); + assert_eq!(encode(&text, info), input); + } + + #[test] + fn mjb_llr_115_utf16_handles_non_ascii_and_surrogates() { + // 文 is BMP; 𝄞 (U+1D11E) needs a surrogate pair in UTF-16. + let original = "文𝄞"; + let info = EncodingInfo { + encoding: UTF_16LE, + has_bom: true, + }; + let bytes = encode(original, info); + let (back, _) = decode(&bytes).unwrap(); + assert_eq!(back, original); + } + + /// MJB-LLR-113: a *declared* non-UTF-8 encoding is transcoded, not + /// rejected. An unpaired surrogate is repaired, and that repair is + /// reproducible because the encoding is known. + #[test] + fn mjb_llr_113_declared_utf16_is_transcoded_not_rejected() { + // UTF-16LE BOM, then a lone high surrogate (0xD800) — not valid UTF-16. + let input = vec![0xFF, 0xFE, 0x00, 0xD8, b'a', 0x00]; + let (text, info) = decode(&input).expect("a declared encoding must not be rejected"); + assert_eq!(info.encoding, UTF_16LE); + assert!( + text.contains('\u{FFFD}'), + "the unpaired surrogate becomes U+FFFD, got {text:?}" + ); + } + + /// MJB-LLR-118: the UTF-8 exception. Invalid UTF-8 is rejected outright + /// rather than repaired, because a repair would be written back over the + /// user's data on save. + #[test] + fn mjb_llr_118_invalid_utf8_is_rejected() { + // 0xFF is never valid in UTF-8. + let err = decode(&[b'a', 0xFF, b'b']).unwrap_err(); + assert_eq!(err, DecodeError::InvalidUtf8 { valid_up_to: 1 }); + } + + #[test] + fn mjb_llr_118_rejection_names_the_offset() { + let err = decode(&[b'h', b'i', 0x80]).unwrap_err(); + assert_eq!(err, DecodeError::InvalidUtf8 { valid_up_to: 2 }); + assert!( + err.to_string().contains('2'), + "the message must locate the bad byte, got: {err}" + ); + } + + /// A truncated multi-byte character is invalid UTF-8 too. + #[test] + fn mjb_llr_118_truncated_multibyte_char_is_rejected() { + // 文 is E6 96 87; drop the last byte. + let err = decode(&[0xE6, 0x96]).unwrap_err(); + assert_eq!(err, DecodeError::InvalidUtf8 { valid_up_to: 0 }); + } + + /// MJB-LLR-118: a UTF-8 *BOM* does not license invalid bytes after it. + #[test] + fn mjb_llr_118_declared_utf8_is_strict_too() { + let input = vec![0xEF, 0xBB, 0xBF, b'a', 0xFF]; + let err = decode(&input).unwrap_err(); + assert_eq!( + err, + DecodeError::InvalidUtf8 { valid_up_to: 1 }, + "offset is measured past the BOM" + ); + } + + /// Valid multi-byte UTF-8 must not be mistaken for invalid. + #[test] + fn mjb_llr_118_valid_multibyte_utf8_is_accepted() { + for s in ["文字化け", "e\u{0301}", "𝄞", "café", "", "\u{FFFD}"] { + let (text, _) = decode(s.as_bytes()) + .unwrap_or_else(|e| panic!("{s:?} must decode, got {e}")); + assert_eq!(text, s); + } + } + + #[test] + fn mjb_llr_113_empty_input_decodes_to_empty() { + let (text, info) = decode(b"").unwrap(); + assert_eq!(text, ""); + assert!(!info.has_bom); + } +} diff --git a/src/buffer/grapheme.rs b/src/buffer/grapheme.rs new file mode 100644 index 0000000..8670853 --- /dev/null +++ b/src/buffer/grapheme.rs @@ -0,0 +1,325 @@ +//! Grapheme cluster boundaries and display width over a rope. +//! +//! Under byte indexing an offset can land inside a character or inside a +//! grapheme cluster, so every cursor position the user can observe is snapped +//! to a grapheme boundary here. See MJB-DR-002. +//! +//! `unicode_segmentation::GraphemeCursor` works over `&str` fragments and asks +//! for more context when a cluster straddles a fragment edge; ropey's +//! `chunk(byte_idx) -> (&str, chunk_start)` supplies exactly that, so clusters +//! spanning chunk boundaries resolve correctly (MJB-LLR-022). + +use std::borrow::Cow; + +use ropey::RopeSlice; +use unicode_segmentation::{GraphemeCursor, GraphemeIncomplete}; +use unicode_width::UnicodeWidthStr; + +/// Columns a tab advances to. Fixed rather than configurable; a configurable +/// tab stop would be a new requirement, not a derived one. +pub const TAB_WIDTH: usize = 4; + +/// MJB-LLR-020: byte offset of the grapheme boundary preceding `byte_idx`, +/// or 0 when there is none. +pub fn prev_grapheme_boundary(slice: RopeSlice, byte_idx: usize) -> usize { + let len = slice.len(); + let byte_idx = slice.floor_char_boundary(byte_idx.min(len)); + if byte_idx == 0 { + return 0; + } + + let mut cursor = GraphemeCursor::new(byte_idx, len, true); + let (mut chunk, mut chunk_start) = slice.chunk(byte_idx); + + loop { + match cursor.prev_boundary(chunk, chunk_start) { + Ok(Some(n)) => return n, + Ok(None) => return 0, + Err(GraphemeIncomplete::PrevChunk) => { + // Step back one chunk and retry. + let (c, s) = slice.chunk(chunk_start.saturating_sub(1)); + chunk = c; + chunk_start = s; + } + Err(GraphemeIncomplete::PreContext(n)) => { + let (ctx, ctx_start) = slice.chunk(n.saturating_sub(1)); + cursor.provide_context(ctx, ctx_start); + } + // The remaining variants cannot arise from prev_boundary with a + // cursor built over the whole slice; treat defensively as "no + // boundary found" rather than panicking (MJB-HLR-018). + Err(_) => return 0, + } + } +} + +/// MJB-LLR-021: byte offset of the grapheme boundary following `byte_idx`, +/// or `slice.len()` when there is none. +pub fn next_grapheme_boundary(slice: RopeSlice, byte_idx: usize) -> usize { + let len = slice.len(); + let byte_idx = slice.floor_char_boundary(byte_idx.min(len)); + if byte_idx >= len { + return len; + } + + let mut cursor = GraphemeCursor::new(byte_idx, len, true); + let (mut chunk, mut chunk_start) = slice.chunk(byte_idx); + + loop { + match cursor.next_boundary(chunk, chunk_start) { + Ok(Some(n)) => return n, + Ok(None) => return len, + Err(GraphemeIncomplete::NextChunk) => { + let next_start = chunk_start + chunk.len(); + if next_start >= len { + return len; + } + let (c, s) = slice.chunk(next_start); + chunk = c; + chunk_start = s; + } + Err(GraphemeIncomplete::PreContext(n)) => { + let (ctx, ctx_start) = slice.chunk(n.saturating_sub(1)); + cursor.provide_context(ctx, ctx_start); + } + Err(_) => return len, + } + } +} + +/// MJB-LLR-023: whether `byte_idx` lies on a grapheme cluster boundary. +pub fn is_grapheme_boundary(slice: RopeSlice, byte_idx: usize) -> bool { + let len = slice.len(); + if byte_idx > len || !slice.is_char_boundary(byte_idx) { + return false; + } + if byte_idx == 0 || byte_idx == len { + return true; + } + + let mut cursor = GraphemeCursor::new(byte_idx, len, true); + let (chunk, chunk_start) = slice.chunk(byte_idx); + + loop { + match cursor.is_boundary(chunk, chunk_start) { + Ok(b) => return b, + Err(GraphemeIncomplete::PreContext(n)) => { + let (ctx, ctx_start) = slice.chunk(n.saturating_sub(1)); + cursor.provide_context(ctx, ctx_start); + } + Err(_) => return false, + } + } +} + +/// The text in `byte_range` without allocating when it lies in one rope chunk. +/// +/// Rendering asks for every grapheme on every visible line each frame, so the +/// obvious `slice.chunks().collect::<String>()` would allocate once per cell +/// per frame. A grapheme spans a chunk boundary only rarely, and only then is +/// a copy made. +pub fn grapheme_str(slice: RopeSlice<'_>, byte_range: std::ops::Range<usize>) -> Cow<'_, str> { + let sub = slice.slice(byte_range); + match sub.as_str() { + Some(s) => Cow::Borrowed(s), + None => Cow::Owned(sub.chunks().collect()), + } +} + +/// MJB-LLR-024: terminal display width of one grapheme cluster. +/// +/// A tab is width-dependent on where it starts, so callers pass the column it +/// begins at. Control characters render as nothing and count zero. +pub fn grapheme_width(grapheme: &str, at_column: usize) -> usize { + if grapheme == "\t" { + return TAB_WIDTH - (at_column % TAB_WIDTH); + } + if grapheme.chars().all(|c| c.is_control()) { + return 0; + } + UnicodeWidthStr::width(grapheme) +} + +/// MJB-LLR-025: display column of `byte_idx` within `line`, accumulating +/// grapheme widths rather than counting bytes. +pub fn display_column(line: RopeSlice, byte_idx: usize) -> usize { + let limit = line.floor_char_boundary(byte_idx.min(line.len())); + let mut column = 0; + let mut pos = 0; + + while pos < limit { + let next = next_grapheme_boundary(line, pos); + if next <= pos { + break; + } + let g = grapheme_str(line, pos..next.min(limit)); + column += grapheme_width(&g, column); + pos = next; + } + column +} + +/// Inverse of [`display_column`]: the byte offset within `line` whose display +/// column is nearest to but not beyond `target_column`. Used to preserve the +/// visual column across vertical motion (MJB-LLR-064). +pub fn byte_at_display_column(line: RopeSlice, target_column: usize) -> usize { + let len = line.len(); + let mut column = 0; + let mut pos = 0; + + while pos < len && column < target_column { + let next = next_grapheme_boundary(line, pos); + if next <= pos { + break; + } + let g = grapheme_str(line, pos..next); + // A line terminator is not a landing position. + if g.starts_with('\n') || g.starts_with('\r') { + break; + } + column += grapheme_width(&g, column); + if column > target_column { + break; + } + pos = next; + } + pos +} + +#[cfg(test)] +mod tests { + use ropey::Rope; + + use super::*; + + #[test] + fn mjb_llr_020_prev_boundary_saturates_at_zero() { + let r = Rope::from_str("abc"); + assert_eq!(prev_grapheme_boundary(r.slice(..), 0), 0); + assert_eq!(prev_grapheme_boundary(r.slice(..), 1), 0); + assert_eq!(prev_grapheme_boundary(r.slice(..), 3), 2); + } + + #[test] + fn mjb_llr_021_next_boundary_saturates_at_end() { + let r = Rope::from_str("abc"); + assert_eq!(next_grapheme_boundary(r.slice(..), 3), 3); + assert_eq!(next_grapheme_boundary(r.slice(..), 0), 1); + // Beyond the end must clamp rather than panic. + assert_eq!(next_grapheme_boundary(r.slice(..), 99), 3); + } + + #[test] + fn mjb_llr_021_multibyte_advances_whole_char() { + // 文 is 3 bytes; a boundary must not land inside it. + let r = Rope::from_str("文字化け"); + assert_eq!(next_grapheme_boundary(r.slice(..), 0), 3); + assert_eq!(prev_grapheme_boundary(r.slice(..), 3), 0); + } + + #[test] + fn mjb_llr_020_combining_mark_is_one_cluster() { + // "e" + U+0301 COMBINING ACUTE ACCENT is a single grapheme cluster. + let r = Rope::from_str("e\u{0301}x"); + assert_eq!(next_grapheme_boundary(r.slice(..), 0), 3); + assert_eq!(prev_grapheme_boundary(r.slice(..), 3), 0); + } + + /// MJB-LLR-022: boundaries must resolve identically whether or not the + /// cluster straddles a rope chunk edge. + /// + /// A rope large enough to hold many chunks is built from multi-byte + /// characters, then every boundary is walked and compared against the + /// contiguous `&str` answer. + #[test] + fn mjb_llr_022_boundaries_resolve_across_chunk_edges() { + // Large enough to force ropey to split into multiple chunks. + let source: String = "文字化けe\u{0301}x".repeat(4000); + let r = Rope::from_str(&source); + let s = r.slice(..); + assert!( + s.chunks().count() > 1, + "test is meaningless without multiple chunks" + ); + + // Walk forward over the whole rope, comparing to unicode-segmentation + // over the contiguous string. + use unicode_segmentation::UnicodeSegmentation; + let expected: Vec<usize> = source + .grapheme_indices(true) + .map(|(i, _)| i) + .chain(std::iter::once(source.len())) + .collect(); + + let mut got = vec![0usize]; + let mut pos = 0; + while pos < s.len() { + let next = next_grapheme_boundary(s, pos); + assert!(next > pos, "must make progress at byte {pos}"); + got.push(next); + pos = next; + } + assert_eq!(got, expected, "forward boundaries must match across chunks"); + + // And backward, from the end. + let mut back = vec![s.len()]; + let mut pos = s.len(); + while pos > 0 { + let prev = prev_grapheme_boundary(s, pos); + assert!(prev < pos, "must make progress backward at byte {pos}"); + back.push(prev); + pos = prev; + } + back.reverse(); + assert_eq!(back, expected, "backward boundaries must match across chunks"); + } + + #[test] + fn mjb_llr_023_boundary_detection() { + let r = Rope::from_str("文a"); + let s = r.slice(..); + assert!(is_grapheme_boundary(s, 0)); + assert!(!is_grapheme_boundary(s, 1), "inside a multi-byte char"); + assert!(is_grapheme_boundary(s, 3)); + assert!(is_grapheme_boundary(s, 4)); + } + + #[test] + fn mjb_llr_024_widths() { + assert_eq!(grapheme_width("a", 0), 1); + assert_eq!(grapheme_width("文", 0), 2, "wide char occupies two columns"); + assert_eq!(grapheme_width("\t", 0), TAB_WIDTH); + assert_eq!(grapheme_width("\t", 1), TAB_WIDTH - 1, "tab fills to stop"); + assert_eq!(grapheme_width("\u{0}", 0), 0); + } + + #[test] + fn mjb_llr_025_display_column_counts_width_not_bytes() { + let r = Rope::from_str("文字a"); + // Byte 6 is after two wide chars: four columns, not six. + assert_eq!(display_column(r.slice(..), 6), 4); + assert_eq!(display_column(r.slice(..), 0), 0); + } + + #[test] + fn mjb_llr_025_display_column_tab_expands() { + let r = Rope::from_str("\tx"); + assert_eq!(display_column(r.slice(..), 1), TAB_WIDTH); + } + + #[test] + fn byte_at_display_column_round_trips() { + let r = Rope::from_str("文字a"); + let s = r.slice(..); + assert_eq!(byte_at_display_column(s, 4), 6); + assert_eq!(byte_at_display_column(s, 0), 0); + // Past the end of the line clamps to the line's length. + assert_eq!(byte_at_display_column(s, 99), s.len()); + } + + #[test] + fn byte_at_display_column_stops_before_terminator() { + let r = Rope::from_str("ab\n"); + assert_eq!(byte_at_display_column(r.slice(..), 99), 2); + } +} diff --git a/src/buffer/history.rs b/src/buffer/history.rs new file mode 100644 index 0000000..e4668fc --- /dev/null +++ b/src/buffer/history.rs @@ -0,0 +1,228 @@ +//! Undo/redo history (MJB-HLR-011). +//! +//! Each committed edit stores the pair (forward transaction, inverse +//! transaction). `cursor` is the number of entries currently *applied*, so +//! entries at or beyond it have been reverted and are available to redo. +//! +//! Undo at the start and redo at the end are no-ops, not errors — reaching +//! either end is ordinary use, not a fault (MJB-LLR-052, MJB-LLR-053). + +use super::transaction::Transaction; + +#[derive(Debug, Clone)] +struct Entry { + forward: Transaction, + inverse: Transaction, + /// Identifies the buffer state this entry produces. Never reused. + id: usize, +} + +#[derive(Debug, Clone, Default)] +pub struct History { + entries: Vec<Entry>, + /// Number of entries applied; also the index of the next redo. + cursor: usize, + /// Monotonic source of entry ids. + /// + /// Deliberately *not* the same thing as `cursor`. Using stack depth to + /// identify a state is wrong: saving at depth 3, undoing, then making a + /// different edit returns to depth 3 while the content differs, so a + /// depth comparison would report the buffer clean when it is not. + next_id: usize, +} + +impl History { + pub fn new() -> Self { + Self::default() + } + + /// MJB-LLR-051: record an applied edit. + /// + /// Anything previously undone is discarded — committing a new edit after an + /// undo abandons the branch that was reverted. + pub fn commit(&mut self, forward: Transaction, inverse: Transaction) { + self.entries.truncate(self.cursor); + self.next_id += 1; + self.entries.push(Entry { + forward, + inverse, + id: self.next_id, + }); + self.cursor = self.entries.len(); + } + + /// MJB-LLR-052: the transaction that reverts the most recent edit, or + /// `None` when nothing remains to undo. + pub fn undo(&mut self) -> Option<&Transaction> { + if self.cursor == 0 { + return None; + } + self.cursor -= 1; + Some(&self.entries[self.cursor].inverse) + } + + /// MJB-LLR-053: the transaction that reapplies the most recently undone + /// edit, or `None` when nothing remains to redo. + pub fn redo(&mut self) -> Option<&Transaction> { + if self.cursor >= self.entries.len() { + return None; + } + let t = &self.entries[self.cursor].forward; + self.cursor += 1; + Some(t) + } + + /// Identifies the current buffer state. + /// + /// Zero means pristine — no edit applied. Otherwise it is the id of the + /// most recently applied entry. Two calls return the same value exactly + /// when the buffer content is the same, which is what lets "modified" be + /// *derived* rather than latched; see + /// [`super::document::Document::is_modified`]. + pub fn revision(&self) -> usize { + match self.cursor { + 0 => 0, + n => self.entries[n - 1].id, + } + } + + pub fn can_undo(&self) -> bool { + self.cursor > 0 + } + + pub fn can_redo(&self) -> bool { + self.cursor < self.entries.len() + } + + pub fn len(&self) -> usize { + self.entries.len() + } + + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } +} + +#[cfg(test)] +mod tests { + use ropey::Rope; + + use super::*; + use crate::buffer::transaction::Transaction; + + fn edit(text: &str, from: usize, to: usize, ins: Option<&str>) -> (Transaction, Transaction) { + let r = Rope::from_str(text); + let t = Transaction::change(&r, [(from, to, ins.map(str::to_owned))]); + let inv = Transaction::new(t.changes.invert(&r)); + (t, inv) + } + + #[test] + fn mjb_llr_052_undo_past_start_is_a_noop() { + let mut h = History::new(); + assert!(h.undo().is_none()); + assert!(!h.can_undo()); + // Repeated attempts must stay safe, not underflow the cursor. + assert!(h.undo().is_none()); + assert!(h.undo().is_none()); + } + + #[test] + fn mjb_llr_053_redo_past_end_is_a_noop() { + let mut h = History::new(); + let (f, i) = edit("abc", 0, 1, None); + h.commit(f, i); + assert!(h.redo().is_none(), "nothing has been undone yet"); + assert!(!h.can_redo()); + } + + #[test] + fn mjb_llr_051_commit_then_undo_then_redo() { + let mut h = History::new(); + let (f, i) = edit("abc", 0, 1, None); + h.commit(f, i); + + assert!(h.can_undo()); + assert!(h.undo().is_some()); + assert!(!h.can_undo()); + assert!(h.can_redo()); + assert!(h.redo().is_some()); + assert!(!h.can_redo()); + } + + #[test] + fn mjb_llr_051_commit_after_undo_discards_the_redo_branch() { + let mut h = History::new(); + let (f1, i1) = edit("abc", 0, 1, None); + let (f2, i2) = edit("bc", 0, 1, None); + h.commit(f1, i1); + h.commit(f2, i2); + + h.undo(); + assert!(h.can_redo()); + + let (f3, i3) = edit("bc", 1, 2, None); + h.commit(f3, i3); + assert!(!h.can_redo(), "the undone branch must be discarded"); + assert_eq!(h.len(), 2); + } + + /// MJB-LLR-116: a revision identifies *content*, not stack depth. + /// + /// Regression guard for the trap this replaced: save at depth 2, undo, then + /// make a different edit. The cursor returns to 2, but the buffer no longer + /// matches what was saved, so the revision must differ. + #[test] + fn mjb_llr_116_revision_is_not_stack_depth() { + let mut h = History::new(); + let (f1, i1) = edit("abcdef", 0, 1, None); + let (f2, i2) = edit("bcdef", 0, 1, None); + h.commit(f1, i1); + h.commit(f2, i2); + + let saved = h.revision(); + + h.undo(); + assert_ne!(h.revision(), saved, "undo leaves a different state"); + + // A different second edit, landing at the same stack depth. + let (f3, i3) = edit("bcdef", 1, 2, None); + h.commit(f3, i3); + assert_eq!(h.len(), 2, "same depth as when we saved"); + assert_ne!( + h.revision(), + saved, + "same depth, different content: must not look saved" + ); + } + + #[test] + fn mjb_llr_116_revision_is_zero_when_pristine_and_returns_on_undo() { + let mut h = History::new(); + assert_eq!(h.revision(), 0); + + let (f, i) = edit("abc", 0, 1, None); + h.commit(f, i); + let after = h.revision(); + assert_ne!(after, 0); + + h.undo(); + assert_eq!(h.revision(), 0, "undoing to pristine returns to revision 0"); + h.redo(); + assert_eq!(h.revision(), after, "redo restores the same revision"); + } + + #[test] + fn mjb_llr_052_undo_walks_back_in_order() { + let mut h = History::new(); + for n in 0..3 { + let (f, i) = edit("abcdef", n, n + 1, None); + h.commit(f, i); + } + assert_eq!(h.len(), 3); + for _ in 0..3 { + assert!(h.undo().is_some()); + } + assert!(h.undo().is_none(), "exhausted"); + } +} diff --git a/src/buffer/keymap.rs b/src/buffer/keymap.rs new file mode 100644 index 0000000..61009c3 --- /dev/null +++ b/src/buffer/keymap.rs @@ -0,0 +1,392 @@ +//! Modal keymap resolution (MJB-HLR-015). +//! +//! Replaces the application template's resolver, which looked up single keys +//! first and otherwise accumulated a buffer cleared on every `Action::Tick` — +//! giving multi-key sequences a ~250 ms timeout at the default tick rate, so +//! `gg` failed if typed slowly. Here the set of proper prefixes is precomputed, +//! so resolution is exact and **time-independent** (MJB-LLR-154). +//! +//! A static table cannot express everything a modal editor needs: insert mode +//! must treat any unbound printable key as self-insert, and counts are typed as +//! ordinary digits. Both are handled around the table rather than in it, by +//! [`KeymapResult::Cancelled`] and by count accumulation. + +use std::collections::{HashMap, HashSet}; + +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +use super::command::Command; +use crate::config::{KeyBindings, Mode}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum KeymapResult { + /// The keys so far are a prefix of at least one binding; wait for more. + Pending, + /// A binding matched, carrying any count typed before it. + Matched(Command, Option<usize>), + /// The keys match nothing. Carries what was accumulated so the caller can + /// apply a mode-specific fallback, such as insert-mode self-insert. + Cancelled(Vec<KeyEvent>), +} + +#[derive(Debug, Default)] +pub struct Keymap { + bindings: HashMap<Mode, HashMap<Vec<KeyEvent>, Command>>, + /// MJB-LLR-150: every proper prefix of every bound sequence, per mode. + prefixes: HashMap<Mode, HashSet<Vec<KeyEvent>>>, + pending: Vec<KeyEvent>, + count: Option<usize>, +} + +impl Keymap { + /// MJB-LLR-150 + pub fn new(bindings: &KeyBindings) -> Self { + let mut prefixes: HashMap<Mode, HashSet<Vec<KeyEvent>>> = HashMap::new(); + + for (mode, map) in &bindings.0 { + let set = prefixes.entry(*mode).or_default(); + for keys in map.keys() { + for n in 1..keys.len() { + set.insert(keys[..n].to_vec()); + } + } + } + + Self { + bindings: bindings.0.clone(), + prefixes, + pending: Vec::new(), + count: None, + } + } + + pub fn pending(&self) -> &[KeyEvent] { + &self.pending + } + + pub fn count(&self) -> Option<usize> { + self.count + } + + /// Abandon any partial sequence and count, e.g. on a mode change. + pub fn reset(&mut self) { + self.pending.clear(); + self.count = None; + } + + /// Look up a single key without disturbing pending state. Used by `App` for + /// the `Global` map, which has no multi-key bindings (MJB-LLR-203). + pub fn lookup_single(&self, mode: Mode, key: KeyEvent) -> Option<Command> { + self.bindings.get(&mode)?.get(&vec![key]).copied() + } + + /// MJB-LLR-151..156: feed one key and resolve. + pub fn resolve(&mut self, mode: Mode, key: KeyEvent) -> KeymapResult { + // MJB-LLR-155: digits typed before a command form a count. Only while + // no sequence is pending, so `g` then `1` is not swallowed. + if self.pending.is_empty() + && matches!(mode, Mode::Normal | Mode::Select) + && let KeyCode::Char(c) = key.code + && c.is_ascii_digit() + && !key.modifiers.contains(KeyModifiers::CONTROL) + && !key.modifiers.contains(KeyModifiers::ALT) + { + let digit = (c as u8 - b'0') as usize; + // A leading zero is not a count; it stays available as a binding. + if digit != 0 || self.count.is_some() { + self.count = Some(self.count.unwrap_or(0) * 10 + digit); + return KeymapResult::Pending; + } + } + + self.pending.push(key); + + // MJB-LLR-151 + if let Some(&cmd) = self.bindings.get(&mode).and_then(|m| m.get(&self.pending)) { + let count = self.count.take(); + self.pending.clear(); + return KeymapResult::Matched(cmd, count); + } + + // MJB-LLR-152 + if self + .prefixes + .get(&mode) + .is_some_and(|set| set.contains(&self.pending)) + { + return KeymapResult::Pending; + } + + // MJB-LLR-153 + let keys = std::mem::take(&mut self.pending); + self.count = None; + KeymapResult::Cancelled(keys) + } +} + +/// MJB-LLR-156: the insert-mode fallback — a bare printable character. +/// +/// Control and Alt are excluded so an unbound `Ctrl-x` is discarded rather than +/// inserting `x`. Shift is allowed: it is how capitals are typed. +pub fn self_insert_char(keys: &[KeyEvent]) -> Option<char> { + let [key] = keys else { + return None; + }; + let KeyCode::Char(c) = key.code else { + return None; + }; + if key.modifiers.contains(KeyModifiers::CONTROL) || key.modifiers.contains(KeyModifiers::ALT) { + return None; + } + Some(c) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::parse_key_sequence; + + fn key(c: char) -> KeyEvent { + KeyEvent::new(KeyCode::Char(c), KeyModifiers::empty()) + } + + fn ctrl(c: char) -> KeyEvent { + KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL) + } + + fn keymap() -> Keymap { + let mut bindings = KeyBindings::default(); + let mut normal = HashMap::new(); + normal.insert(parse_key_sequence("<h>").unwrap(), Command::MoveCharLeft); + normal.insert(parse_key_sequence("<g><g>").unwrap(), Command::GotoFileStart); + normal.insert(parse_key_sequence("<g><e>").unwrap(), Command::GotoLastLine); + bindings.0.insert(Mode::Normal, normal); + + let mut insert = HashMap::new(); + insert.insert(parse_key_sequence("<esc>").unwrap(), Command::NormalMode); + bindings.0.insert(Mode::Insert, insert); + + Keymap::new(&bindings) + } + + /// MJB-LLR-150: every proper prefix is precomputed, and only proper + /// prefixes — a complete binding is not itself registered as a prefix, or + /// it would never resolve. + #[test] + fn mjb_llr_150_proper_prefixes_are_precomputed() { + let k = keymap(); + let set = k.prefixes.get(&Mode::Normal).expect("normal prefixes"); + + assert!( + set.contains(&parse_key_sequence("<g>").unwrap()), + "`g` is a proper prefix of `gg` and `ge`" + ); + assert!( + !set.contains(&parse_key_sequence("<g><g>").unwrap()), + "a complete binding must not be registered as a prefix" + ); + assert!( + !set.contains(&parse_key_sequence("<h>").unwrap()), + "a single-key binding has no proper prefix" + ); + } + + #[test] + fn mjb_llr_150_prefix_set_is_empty_for_a_mode_without_sequences() { + let k = keymap(); + let set = k.prefixes.get(&Mode::Insert).expect("insert prefixes"); + assert!(set.is_empty(), "insert has only single-key bindings"); + } + + #[test] + fn mjb_llr_151_single_key_binding_matches_immediately() { + let mut k = keymap(); + assert_eq!( + k.resolve(Mode::Normal, key('h')), + KeymapResult::Matched(Command::MoveCharLeft, None) + ); + assert!(k.pending().is_empty(), "pending must be cleared"); + } + + /// MJB-LLR-152, MJB-LLR-154: `g` is a prefix, so it waits — indefinitely, + /// with no timer involved. This is the bug the old resolver had. + #[test] + fn mjb_llr_152_prefix_key_waits_for_more() { + let mut k = keymap(); + assert_eq!(k.resolve(Mode::Normal, key('g')), KeymapResult::Pending); + assert_eq!(k.pending().len(), 1); + } + + #[test] + fn mjb_llr_151_two_key_sequence_resolves() { + let mut k = keymap(); + assert_eq!(k.resolve(Mode::Normal, key('g')), KeymapResult::Pending); + assert_eq!( + k.resolve(Mode::Normal, key('g')), + KeymapResult::Matched(Command::GotoFileStart, None) + ); + } + + #[test] + fn mjb_llr_151_sequences_sharing_a_prefix_stay_distinct() { + let mut k = keymap(); + k.resolve(Mode::Normal, key('g')); + assert_eq!( + k.resolve(Mode::Normal, key('e')), + KeymapResult::Matched(Command::GotoLastLine, None) + ); + } + + #[test] + fn mjb_llr_153_unknown_continuation_cancels() { + let mut k = keymap(); + k.resolve(Mode::Normal, key('g')); + let got = k.resolve(Mode::Normal, key('z')); + match got { + KeymapResult::Cancelled(keys) => assert_eq!(keys, vec![key('g'), key('z')]), + other => panic!("expected Cancelled, got {other:?}"), + } + assert!(k.pending().is_empty()); + } + + #[test] + fn mjb_llr_153_unbound_key_cancels_immediately() { + let mut k = keymap(); + match k.resolve(Mode::Normal, key('z')) { + KeymapResult::Cancelled(keys) => assert_eq!(keys, vec![key('z')]), + other => panic!("expected Cancelled, got {other:?}"), + } + } + + /// MJB-LLR-154: no elapsed-time input exists, so a pending sequence + /// survives arbitrarily many unrelated resolutions in other modes. + #[test] + fn mjb_llr_154_pending_is_not_discarded_by_time() { + let mut k = keymap(); + k.resolve(Mode::Normal, key('g')); + assert_eq!(k.pending().len(), 1); + // Nothing but another key can advance or clear it. + assert_eq!( + k.resolve(Mode::Normal, key('g')), + KeymapResult::Matched(Command::GotoFileStart, None) + ); + } + + #[test] + fn mjb_llr_155_count_accumulates_and_is_delivered() { + let mut k = keymap(); + assert_eq!(k.resolve(Mode::Normal, key('1')), KeymapResult::Pending); + assert_eq!(k.resolve(Mode::Normal, key('2')), KeymapResult::Pending); + assert_eq!( + k.resolve(Mode::Normal, key('h')), + KeymapResult::Matched(Command::MoveCharLeft, Some(12)) + ); + } + + #[test] + fn mjb_llr_155_count_is_consumed_once() { + let mut k = keymap(); + k.resolve(Mode::Normal, key('3')); + k.resolve(Mode::Normal, key('h')); + assert_eq!( + k.resolve(Mode::Normal, key('h')), + KeymapResult::Matched(Command::MoveCharLeft, None), + "the count must not persist to the next command" + ); + } + + #[test] + fn mjb_llr_155_leading_zero_is_not_a_count() { + let mut k = keymap(); + // `0` with no count in progress falls through to binding lookup. + match k.resolve(Mode::Normal, key('0')) { + KeymapResult::Cancelled(_) => {} + other => panic!("expected Cancelled for unbound 0, got {other:?}"), + } + assert_eq!(k.count(), None); + } + + #[test] + fn mjb_llr_155_zero_extends_an_existing_count() { + let mut k = keymap(); + k.resolve(Mode::Normal, key('1')); + k.resolve(Mode::Normal, key('0')); + assert_eq!( + k.resolve(Mode::Normal, key('h')), + KeymapResult::Matched(Command::MoveCharLeft, Some(10)) + ); + } + + #[test] + fn mjb_llr_155_digits_are_not_counts_in_insert_mode() { + let mut k = keymap(); + match k.resolve(Mode::Insert, key('5')) { + KeymapResult::Cancelled(keys) => { + assert_eq!(self_insert_char(&keys), Some('5'), "must type a 5"); + } + other => panic!("expected Cancelled, got {other:?}"), + } + } + + #[test] + fn mjb_llr_156_self_insert_accepts_plain_printables() { + assert_eq!(self_insert_char(&[key('a')]), Some('a')); + assert_eq!( + self_insert_char(&[KeyEvent::new( + KeyCode::Char('A'), + KeyModifiers::SHIFT + )]), + Some('A'), + "shift is how capitals are typed" + ); + } + + #[test] + fn mjb_llr_156_self_insert_rejects_control_and_alt() { + assert_eq!(self_insert_char(&[ctrl('x')]), None); + assert_eq!( + self_insert_char(&[KeyEvent::new(KeyCode::Char('x'), KeyModifiers::ALT)]), + None + ); + } + + #[test] + fn mjb_llr_156_self_insert_rejects_non_char_and_sequences() { + assert_eq!( + self_insert_char(&[KeyEvent::new(KeyCode::Esc, KeyModifiers::empty())]), + None + ); + assert_eq!( + self_insert_char(&[key('a'), key('b')]), + None, + "only a single key can self-insert" + ); + } + + #[test] + fn mjb_llr_203_lookup_single_does_not_disturb_pending() { + let mut k = keymap(); + k.resolve(Mode::Normal, key('g')); + assert_eq!(k.lookup_single(Mode::Normal, key('h')), Some(Command::MoveCharLeft)); + assert_eq!(k.pending().len(), 1, "global lookup must be side-effect free"); + } + + #[test] + fn reset_clears_pending_and_count() { + let mut k = keymap(); + k.resolve(Mode::Normal, key('3')); + k.resolve(Mode::Normal, key('g')); + k.reset(); + assert!(k.pending().is_empty()); + assert_eq!(k.count(), None); + } + + #[test] + fn unknown_mode_cancels_rather_than_panicking() { + let mut k = keymap(); + match k.resolve(Mode::Command, key('x')) { + KeymapResult::Cancelled(_) => {} + other => panic!("expected Cancelled for an unmapped mode, got {other:?}"), + } + } +} diff --git a/src/buffer/line_ending.rs b/src/buffer/line_ending.rs new file mode 100644 index 0000000..42459de --- /dev/null +++ b/src/buffer/line_ending.rs @@ -0,0 +1,144 @@ +//! Line ending detection and normalisation (MJB-HLR-003). +//! +//! The rope stores LF internally regardless of what the file used; the original +//! terminator is recorded and reapplied on write, so opening and saving a CRLF +//! file does not silently rewrite every line. + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum LineEnding { + #[default] + Lf, + Crlf, + Cr, +} + +impl LineEnding { + pub fn as_str(self) -> &'static str { + match self { + LineEnding::Lf => "\n", + LineEnding::Crlf => "\r\n", + LineEnding::Cr => "\r", + } + } + + /// The platform default, used when a buffer contains no terminator at all. + pub fn platform_default() -> Self { + if cfg!(windows) { + LineEnding::Crlf + } else { + LineEnding::Lf + } + } + + /// MJB-LLR-114: the line ending of the first terminator present. + /// + /// Takes `&str` rather than a `RopeSlice`: detection runs on freshly + /// decoded text before the rope is built, and accepting a rope would force + /// the caller to construct one purely to answer this question. + /// + /// Scans bytes, not chars. CR and LF are ASCII and cannot appear as a + /// continuation byte of a multi-byte sequence, so a byte scan is both + /// correct and free of UTF-8 decoding. + pub fn detect(text: &str) -> Self { + match text.as_bytes().iter().position(|&b| b == b'\n' || b == b'\r') { + Some(i) if text.as_bytes()[i] == b'\n' => LineEnding::Lf, + // A CR followed by LF is CRLF; a CR followed by anything else, or + // by nothing at all, stood alone. + Some(i) if text.as_bytes().get(i + 1) == Some(&b'\n') => LineEnding::Crlf, + Some(_) => LineEnding::Cr, + // MJB-LLR-114: no terminator anywhere. + None => Self::platform_default(), + } + } +} + +/// Rewrite every terminator in `text` (assumed LF-normalised) as `ending`. +pub fn apply(text: &str, ending: LineEnding) -> String { + match ending { + LineEnding::Lf => text.to_owned(), + LineEnding::Crlf => text.replace('\n', "\r\n"), + LineEnding::Cr => text.replace('\n', "\r"), + } +} + +/// Normalise CRLF and lone CR to LF for storage in the rope. +pub fn normalize(text: &str) -> String { + if !text.contains('\r') { + return text.to_owned(); + } + text.replace("\r\n", "\n").replace('\r', "\n") +} + +/// Append a terminator when `text` is non-empty and lacks one (MJB-LLR-185). +pub fn with_final_newline(text: &str) -> String { + if text.is_empty() || text.ends_with('\n') { + text.to_owned() + } else { + format!("{text}\n") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn detect(s: &str) -> LineEnding { + LineEnding::detect(s) + } + + #[test] + fn mjb_llr_114_detects_lf() { + assert_eq!(detect("a\nb\n"), LineEnding::Lf); + } + + #[test] + fn mjb_llr_114_detects_crlf() { + assert_eq!(detect("a\r\nb\r\n"), LineEnding::Crlf); + } + + #[test] + fn mjb_llr_114_detects_lone_cr() { + assert_eq!(detect("a\rb\r"), LineEnding::Cr); + } + + #[test] + fn mjb_llr_114_falls_back_to_platform_default() { + assert_eq!(detect("no terminator"), LineEnding::platform_default()); + assert_eq!(detect(""), LineEnding::platform_default()); + } + + #[test] + fn mjb_llr_114_first_terminator_decides() { + // Mixed endings: the first one wins, as documented. + assert_eq!(detect("a\nb\r\n"), LineEnding::Lf); + assert_eq!(detect("a\r\nb\n"), LineEnding::Crlf); + } + + #[test] + fn normalize_collapses_to_lf() { + assert_eq!(normalize("a\r\nb\r\n"), "a\nb\n"); + assert_eq!(normalize("a\rb\r"), "a\nb\n"); + assert_eq!(normalize("a\nb\n"), "a\nb\n"); + } + + #[test] + fn mjb_llr_115_apply_restores_original_ending() { + assert_eq!(apply("a\nb\n", LineEnding::Crlf), "a\r\nb\r\n"); + assert_eq!(apply("a\nb\n", LineEnding::Cr), "a\rb\r"); + assert_eq!(apply("a\nb\n", LineEnding::Lf), "a\nb\n"); + } + + #[test] + fn crlf_round_trips_through_normalize_and_apply() { + let original = "one\r\ntwo\r\nthree\r\n"; + let stored = normalize(original); + assert_eq!(apply(&stored, LineEnding::Crlf), original); + } + + #[test] + fn mjb_llr_185_final_newline_only_added_when_missing() { + assert_eq!(with_final_newline("a"), "a\n"); + assert_eq!(with_final_newline("a\n"), "a\n", "not doubled"); + assert_eq!(with_final_newline(""), "", "empty buffer stays empty"); + } +} 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) +} diff --git a/src/buffer/movement.rs b/src/buffer/movement.rs new file mode 100644 index 0000000..8dc0ff7 --- /dev/null +++ b/src/buffer/movement.rs @@ -0,0 +1,592 @@ +//! Motions — after Helix's `helix-core/src/movement.rs`. +//! +//! The defining property, and the one most easily got wrong: **word motions +//! return a selection, not a point**. Helix's keymap documents `w` as "move +//! next word start", but `range_to_target` returns a `Range` whose anchor is +//! the pre-motion position and whose head is the target. That is why `d` after +//! `w` deletes a word with no operator-pending machinery anywhere +//! (MJB-HLR-007, MJB-LLR-065..067). +//! +//! Implemented against `RopeSlice::char_indices_at`, which yields +//! `(byte_idx, char)` and supports `prev()`, so both directions are byte-native +//! rather than translated from char offsets. + +use ropey::RopeSlice; + +use super::{ + LINE_TYPE, + grapheme::{byte_at_display_column, display_column, next_grapheme_boundary, prev_grapheme_boundary}, + selection::Range, +}; + +/// MJB-LLR-060: character classes that word motions stop between. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CharCategory { + Eol, + Whitespace, + Word, + Punctuation, +} + +/// MJB-LLR-060 +pub fn categorize_char(c: char) -> CharCategory { + if c == '\n' || c == '\r' { + CharCategory::Eol + } else if c.is_whitespace() { + CharCategory::Whitespace + } else if c.is_alphanumeric() || c == '_' { + CharCategory::Word + } else { + CharCategory::Punctuation + } +} + +/// A coarser classification backing the long-word motions `W`/`B`/`E`, which +/// treat punctuation as part of the word. +fn categorize_long(c: char) -> CharCategory { + match categorize_char(c) { + CharCategory::Punctuation => CharCategory::Word, + other => other, + } +} + +/// MJB-LLR-061 +pub fn is_word_boundary(a: char, b: char) -> bool { + categorize_char(a) != categorize_char(b) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WordTarget { + NextStart, + NextEnd, + PrevStart, +} + +fn categorizer(long: bool) -> fn(char) -> CharCategory { + if long { categorize_long } else { categorize_char } +} + +/// MJB-LLR-062: one grapheme left, collapsing to a point. +pub fn move_char_left(text: RopeSlice, range: Range, count: usize) -> Range { + let mut pos = range.cursor(text); + for _ in 0..count.max(1) { + let next = prev_grapheme_boundary(text, pos); + if next == pos { + break; // MJB-LLR-062: no-op at offset zero + } + pos = next; + } + Range::point(pos).clamped(text) +} + +/// MJB-LLR-063: one grapheme right, collapsing to a point. +pub fn move_char_right(text: RopeSlice, range: Range, count: usize) -> Range { + let mut pos = range.cursor(text); + for _ in 0..count.max(1) { + let next = next_grapheme_boundary(text, pos); + if next == pos { + break; // MJB-LLR-063: no-op at end of buffer + } + pos = next; + } + Range::point(pos).clamped(text) +} + +/// MJB-LLR-064: vertical motion preserving the display column. +pub fn move_vertically(text: RopeSlice, range: Range, count: usize, down: bool) -> Range { + let cursor = range.cursor(text); + let line = text.byte_to_line_idx(cursor, LINE_TYPE); + let line_start = text.line_to_byte_idx(line, LINE_TYPE); + let column = display_column(text.line(line, LINE_TYPE), cursor - line_start); + + let last_line = text.len_lines(LINE_TYPE).saturating_sub(1); + let target_line = if down { + line.saturating_add(count.max(1)).min(last_line) + } else { + line.saturating_sub(count.max(1)) + }; + + // MJB-LLR-064: a no-op on the first or last line. + if target_line == line { + return range; + } + + let target_start = text.line_to_byte_idx(target_line, LINE_TYPE); + let offset = byte_at_display_column(text.line(target_line, LINE_TYPE), column); + Range::point(target_start + offset).clamped(text) +} + +/// MJB-LLR-065..069: word motion. +/// +/// Returns a range spanning from the pre-motion cursor to the target, so the +/// traversed text ends up selected. +pub fn word_move( + text: RopeSlice, + range: Range, + count: usize, + target: WordTarget, + long: bool, +) -> Range { + let cat = categorizer(long); + + // The anchor is the position the whole traversal started from and does not + // move; only the head advances, once per count. Re-deriving the start from + // the partial result each iteration would restart from the *cursor* — one + // grapheme behind the head — so `2w` would stall inside the first gap + // instead of reaching the second word. + let anchor = range.cursor(text); + let mut head = anchor; + + for _ in 0..count.max(1) { + let next = match target { + WordTarget::NextStart => next_word_start(text, head, cat), + WordTarget::NextEnd => next_word_end(text, head, cat), + WordTarget::PrevStart => prev_word_start(text, head, cat), + }; + if next == head { + break; // MJB-LLR-069: at the buffer boundary + } + head = next; + } + + if head == anchor { + range + } else { + Range::new(anchor, head).clamped(text) + } +} + +/// Characters that separate words rather than belonging to one. +fn is_separator(category: CharCategory) -> bool { + matches!(category, CharCategory::Whitespace | CharCategory::Eol) +} + +/// The char starting at byte `i`, with its start index. +fn char_at(text: RopeSlice, i: usize) -> Option<(usize, char)> { + (i < text.len()).then(|| text.char_indices_at(i).next())? +} + +/// The char ending at byte `i` — the one immediately before it. +fn char_before(text: RopeSlice, i: usize) -> Option<(usize, char)> { + (i > 0).then(|| text.char_indices_at(i).prev())? +} + +/// MJB-LLR-065: first character of the word after `from`. +/// +/// Runs out whatever category the cursor sits on, then skips separators. +fn next_word_start(text: RopeSlice, from: usize, cat: fn(char) -> CharCategory) -> usize { + let mut i = from; + + if let Some((_, c)) = char_at(text, i) { + let run = cat(c); + while let Some((s, ch)) = char_at(text, i) { + if cat(ch) != run { + break; + } + i = s + ch.len_utf8(); + } + } + + // MJB-LLR-068 + while let Some((s, c)) = char_at(text, i) { + if !is_separator(cat(c)) { + break; + } + i = s + c.len_utf8(); + } + + i +} + +/// MJB-LLR-067: one past the last character of the word after `from`. +/// +/// Steps off the current character first so `e` always advances, even when it +/// already sits on the final character of a word. +fn next_word_end(text: RopeSlice, from: usize, cat: fn(char) -> CharCategory) -> usize { + let mut i = from; + + if let Some((s, c)) = char_at(text, i) { + i = s + c.len_utf8(); + } + + // MJB-LLR-068 + while let Some((s, c)) = char_at(text, i) { + if !is_separator(cat(c)) { + break; + } + i = s + c.len_utf8(); + } + + if let Some((_, c)) = char_at(text, i) { + let run = cat(c); + while let Some((s, ch)) = char_at(text, i) { + if cat(ch) != run { + break; + } + i = s + ch.len_utf8(); + } + } + + i +} + +/// MJB-LLR-066: first character of the word before `from`. +fn prev_word_start(text: RopeSlice, from: usize, cat: fn(char) -> CharCategory) -> usize { + let mut i = from; + + // MJB-LLR-068: skip separators immediately behind the cursor. + while let Some((s, c)) = char_before(text, i) { + if !is_separator(cat(c)) { + break; + } + i = s; + } + + let Some((_, c)) = char_before(text, i) else { + return i; // MJB-LLR-069: nothing but separators behind us + }; + let run = cat(c); + + while let Some((s, ch)) = char_before(text, i) { + if cat(ch) != run { + break; + } + i = s; + } + + i +} + +// --- Goto commands (MJB-HLR-008) --- + +/// MJB-LLR-070 +pub fn goto_file_start(text: RopeSlice) -> Range { + let _ = text; + Range::point(0) +} + +/// MJB-LLR-071 +pub fn goto_last_line(text: RopeSlice) -> Range { + let last = last_content_line(text); + Range::point(text.line_to_byte_idx(last, LINE_TYPE)).clamped(text) +} + +/// MJB-LLR-072 +pub fn goto_line_start(text: RopeSlice, range: Range) -> Range { + let line = range.cursor_line(text); + Range::point(text.line_to_byte_idx(line, LINE_TYPE)).clamped(text) +} + +/// MJB-LLR-073: the last character of the line, excluding its terminator. +pub fn goto_line_end(text: RopeSlice, range: Range) -> Range { + let line = range.cursor_line(text); + Range::point(line_end_byte(text, line)).clamped(text) +} + +/// First non-whitespace character of the cursor's line. +pub fn goto_first_non_whitespace(text: RopeSlice, range: Range) -> Range { + let line = range.cursor_line(text); + let start = text.line_to_byte_idx(line, LINE_TYPE); + let slice = text.line(line, LINE_TYPE); + + let mut offset = 0; + for (i, c) in slice.char_indices() { + if !c.is_whitespace() || matches!(categorize_char(c), CharCategory::Eol) { + offset = i; + break; + } + offset = i + c.len_utf8(); + } + Range::point(start + offset).clamped(text) +} + +/// Byte offset just past the last non-terminator character of `line`. +/// +/// Inspects the final bytes rather than materialising the line: LF and CR are +/// single-byte ASCII and cannot occur as a continuation byte of a multi-byte +/// character, so testing the trailing bytes is unambiguous. +pub fn line_end_byte(text: RopeSlice, line: usize) -> usize { + let start = text.line_to_byte_idx(line, LINE_TYPE); + let slice = text.line(line, LINE_TYPE); + let mut end = slice.len(); + + if end > 0 && slice.byte(end - 1) == b'\n' { + end -= 1; + if end > 0 && slice.byte(end - 1) == b'\r' { + end -= 1; // CRLF + } + } else if end > 0 && slice.byte(end - 1) == b'\r' { + end -= 1; // lone CR + } + + start + end +} + +/// The last line holding content. +/// +/// A buffer ending in a newline reports a trailing empty line; the cursor +/// should land on the last line with text on it. +pub fn last_content_line(text: RopeSlice) -> usize { + let lines = text.len_lines(LINE_TYPE); + if lines == 0 { + return 0; + } + let last = lines - 1; + if last > 0 && text.line(last, LINE_TYPE).len() == 0 { + last - 1 + } else { + last + } +} + +#[cfg(test)] +mod tests { + use ropey::Rope; + + use super::*; + + fn r(s: &str) -> Rope { + Rope::from_str(s) + } + + #[test] + fn mjb_llr_060_categories() { + assert_eq!(categorize_char('a'), CharCategory::Word); + assert_eq!(categorize_char('_'), CharCategory::Word); + assert_eq!(categorize_char('7'), CharCategory::Word); + assert_eq!(categorize_char(' '), CharCategory::Whitespace); + assert_eq!(categorize_char('\n'), CharCategory::Eol); + assert_eq!(categorize_char('.'), CharCategory::Punctuation); + } + + #[test] + fn mjb_llr_061_word_boundary_is_category_change() { + assert!(is_word_boundary('a', ' ')); + assert!(is_word_boundary('a', '.')); + assert!(!is_word_boundary('a', 'b')); + } + + #[test] + fn mjb_llr_062_move_char_left_stops_at_zero() { + let t = r("abc"); + let s = t.slice(..); + assert_eq!(move_char_left(s, Range::point(0), 1), Range::point(0)); + assert_eq!(move_char_left(s, Range::point(2), 1), Range::point(1)); + } + + #[test] + fn mjb_llr_063_move_char_right_stops_at_end() { + let t = r("abc"); + let s = t.slice(..); + assert_eq!(move_char_right(s, Range::point(3), 1), Range::point(3)); + assert_eq!(move_char_right(s, Range::point(0), 1), Range::point(1)); + } + + #[test] + fn mjb_llr_063_move_char_right_skips_whole_multibyte_char() { + let t = r("文a"); + let s = t.slice(..); + assert_eq!(move_char_right(s, Range::point(0), 1), Range::point(3)); + } + + #[test] + fn mjb_llr_064_vertical_motion_preserves_column() { + let t = r("abcdef\nghijkl\n"); + let s = t.slice(..); + let down = move_vertically(s, Range::point(3), 1, true); + assert_eq!(down.cursor(s), 7 + 3, "same column on the next line"); + let up = move_vertically(s, down, 1, false); + assert_eq!(up.cursor(s), 3); + } + + #[test] + fn mjb_llr_064_vertical_motion_clamps_to_short_line() { + let t = r("abcdef\nxy\n"); + let s = t.slice(..); + let down = move_vertically(s, Range::point(5), 1, true); + // Line "xy" has no column 5; clamp to its end. + assert_eq!(down.cursor(s), 7 + 2); + } + + #[test] + fn mjb_llr_064_vertical_motion_is_noop_at_edges() { + let t = r("abc\ndef\n"); + let s = t.slice(..); + let up = move_vertically(s, Range::point(1), 1, false); + assert_eq!(up, Range::point(1), "no-op on the first line"); + } + + /// The behaviour that distinguishes Helix from Vim: `w` leaves a selection. + #[test] + fn mjb_llr_065_next_word_start_produces_a_selection() { + let t = r("hello world"); + let s = t.slice(..); + let got = word_move(s, Range::point(0), 1, WordTarget::NextStart, false); + assert!(!got.is_empty(), "w must leave a selection, not a point"); + assert_eq!(got.anchor, 0, "anchor stays at the pre-motion cursor"); + assert_eq!(got.head, 6, "head lands on the next word's first char"); + } + + #[test] + fn mjb_llr_067_next_word_end_spans_the_word() { + let t = r("hello world"); + let s = t.slice(..); + let got = word_move(s, Range::point(0), 1, WordTarget::NextEnd, false); + assert_eq!(got.anchor, 0); + assert_eq!(got.head, 5, "inclusive of the word's last character"); + } + + #[test] + fn mjb_llr_066_prev_word_start_spans_backward() { + let t = r("hello world"); + let s = t.slice(..); + let got = word_move(s, Range::point(6), 1, WordTarget::PrevStart, false); + assert_eq!(got.anchor, 6, "anchor stays at the pre-motion cursor"); + assert_eq!(got.head, 0); + } + + #[test] + fn mjb_llr_068_word_motion_stops_at_punctuation() { + let t = r("foo.bar"); + let s = t.slice(..); + let got = word_move(s, Range::point(0), 1, WordTarget::NextStart, false); + assert_eq!(got.head, 3, "punctuation is its own category"); + } + + #[test] + fn mjb_llr_068_long_word_motion_absorbs_punctuation() { + let t = r("foo.bar baz"); + let s = t.slice(..); + let got = word_move(s, Range::point(0), 1, WordTarget::NextStart, true); + assert_eq!(got.head, 8, "W treats foo.bar as one word"); + } + + /// MJB-LLR-065 with a count. Regression guard: when each iteration + /// re-derived its start from the partial range's *cursor* — one grapheme + /// behind the head — `2w` stalled inside the first gap instead of reaching + /// the second word. + #[test] + fn mjb_llr_065_counted_next_word_start_advances_once_per_count() { + let t = r("aaa bbb ccc ddd"); + let s = t.slice(..); + + let one = word_move(s, Range::point(0), 1, WordTarget::NextStart, false); + assert_eq!((one.anchor, one.head), (0, 4)); + + let two = word_move(s, Range::point(0), 2, WordTarget::NextStart, false); + assert_eq!( + (two.anchor, two.head), + (0, 8), + "2w must reach the third word's start, not stall in a gap" + ); + + let three = word_move(s, Range::point(0), 3, WordTarget::NextStart, false); + assert_eq!((three.anchor, three.head), (0, 12)); + } + + #[test] + fn mjb_llr_066_counted_prev_word_start_advances_once_per_count() { + let t = r("aaa bbb ccc"); + let s = t.slice(..); + let two = word_move(s, Range::point(10), 2, WordTarget::PrevStart, false); + assert_eq!(two.anchor, 10, "anchor stays at the origin"); + assert_eq!(two.head, 4, "two words back"); + } + + #[test] + fn mjb_llr_067_counted_next_word_end_advances_once_per_count() { + let t = r("aaa bbb ccc"); + let s = t.slice(..); + let two = word_move(s, Range::point(0), 2, WordTarget::NextEnd, false); + assert_eq!((two.anchor, two.head), (0, 7), "end of the second word"); + } + + /// A count larger than the remaining words must saturate, not overshoot. + #[test] + fn mjb_llr_069_counted_motion_saturates_at_the_buffer_end() { + let t = r("aaa bbb"); + let s = t.slice(..); + let got = word_move(s, Range::point(0), 99, WordTarget::NextStart, false); + assert!(got.head <= s.len()); + assert_eq!(got.anchor, 0); + } + + #[test] + fn mjb_llr_069_word_motion_is_noop_at_boundaries() { + let t = r("abc"); + let s = t.slice(..); + let end = Range::point(3); + assert_eq!(word_move(s, end, 1, WordTarget::NextStart, false), end); + let start = Range::point(0); + assert_eq!(word_move(s, start, 1, WordTarget::PrevStart, false), start); + } + + #[test] + fn mjb_llr_068_word_motion_crosses_line_endings() { + let t = r("foo\nbar"); + let s = t.slice(..); + let got = word_move(s, Range::point(0), 1, WordTarget::NextStart, false); + assert_eq!(got.head, 4, "newline is skipped as a separator"); + } + + #[test] + fn mjb_llr_070_goto_file_start() { + let t = r("abc\ndef"); + assert_eq!(goto_file_start(t.slice(..)), Range::point(0)); + } + + #[test] + fn mjb_llr_071_goto_last_line() { + let t = r("abc\ndef\n"); + let s = t.slice(..); + // Trailing newline must not park the cursor on the phantom line. + assert_eq!(goto_last_line(s).cursor(s), 4); + } + + #[test] + fn mjb_llr_071_goto_last_line_without_trailing_newline() { + let t = r("abc\ndef"); + let s = t.slice(..); + assert_eq!(goto_last_line(s).cursor(s), 4); + } + + #[test] + fn mjb_llr_072_goto_line_start() { + let t = r("abc\ndef\n"); + let s = t.slice(..); + assert_eq!(goto_line_start(s, Range::point(6)).cursor(s), 4); + } + + #[test] + fn mjb_llr_073_goto_line_end_excludes_terminator() { + let t = r("abc\ndef\n"); + let s = t.slice(..); + assert_eq!(goto_line_end(s, Range::point(0)).cursor(s), 3); + } + + #[test] + fn mjb_llr_073_goto_line_end_handles_crlf() { + let t = r("abc\r\ndef"); + let s = t.slice(..); + assert_eq!(goto_line_end(s, Range::point(0)).cursor(s), 3); + } + + #[test] + fn goto_first_non_whitespace_skips_indent() { + let t = r(" indented\n"); + let s = t.slice(..); + assert_eq!(goto_first_non_whitespace(s, Range::point(0)).cursor(s), 4); + } + + #[test] + fn empty_buffer_motions_are_safe() { + let t = r(""); + let s = t.slice(..); + assert_eq!(move_char_left(s, Range::point(0), 1), Range::point(0)); + assert_eq!(move_char_right(s, Range::point(0), 1), Range::point(0)); + assert_eq!(goto_line_end(s, Range::point(0)).cursor(s), 0); + assert_eq!(goto_last_line(s).cursor(s), 0); + let w = word_move(s, Range::point(0), 1, WordTarget::NextStart, false); + assert_eq!(w, Range::point(0)); + } +} diff --git a/src/buffer/save.rs b/src/buffer/save.rs new file mode 100644 index 0000000..70dacd0 --- /dev/null +++ b/src/buffer/save.rs @@ -0,0 +1,387 @@ +//! File writing — ported from Helix's `helix-view/src/document.rs::save_impl`. +//! +//! Two distinct fallbacks, both required by MJB-HLR-017 and easy to conflate: +//! +//! 1. **copy instead of rename** when the target is a symlink or hardlink. +//! Renaming the backup into place would break the link; copying preserves it. +//! 2. **restore on failure** — the backup lives in the target's *own directory* +//! so the rename can never cross a filesystem, and is put back if the write +//! fails partway. +//! +//! The ordering matters: every check that can reject the write runs before +//! anything on disk is touched. + +use std::{ + fs, + io::{self, Write}, + path::{Path, PathBuf}, +}; + +#[derive(Debug, thiserror::Error)] +pub enum SaveError { + #[error("no file name associated with this buffer")] + NoPath, + #[error("path is read only: {0}")] + ReadOnly(PathBuf), + #[error("can't save file, parent directory does not exist (use :w! to create it): {0}")] + NoParent(PathBuf), + #[error("io error: {0}")] + Io(#[from] io::Error), +} + +/// MJB-LLR-131: writable without modifying anything. +/// +/// A path that does not exist is *not* read-only — it may still be creatable. +pub fn readonly(path: &Path) -> bool { + match fs::metadata(path) { + Ok(md) => md.permissions().readonly(), + Err(e) if e.kind() == io::ErrorKind::NotFound => false, + Err(_) => true, + } +} + +/// MJB-LLR-130: follow a symlink to its target so the link itself survives. +/// +/// A relative link target is resolved against the link's own directory. +pub fn resolve_write_path(path: &Path) -> PathBuf { + match fs::read_link(path) { + Ok(target) => { + if target.is_relative() { + path.parent() + .map(|parent| parent.join(&target)) + .unwrap_or(target) + } else { + target + } + } + Err(_) => path.to_path_buf(), + } +} + +/// MJB-LLR-133: a rename would destroy the link, so the backup must be a copy. +pub fn must_copy(path: &Path) -> bool { + if fs::symlink_metadata(path) + .map(|md| md.file_type().is_symlink()) + .unwrap_or(false) + { + return true; + } + hard_link_count(path) > 1 +} + +#[cfg(unix)] +fn hard_link_count(path: &Path) -> u64 { + use std::os::unix::fs::MetadataExt; + fs::metadata(path).map(|md| md.nlink()).unwrap_or(1) +} + +#[cfg(not(unix))] +fn hard_link_count(_path: &Path) -> u64 { + 1 +} + +/// Copy permissions from `from` onto `to` (MJB-LLR-136). +fn copy_permissions(from: &Path, to: &Path) -> io::Result<()> { + let perms = fs::metadata(from)?.permissions(); + fs::set_permissions(to, perms) +} + +/// MJB-LLR-134: a backup path beside the target, so `rename` stays within one +/// filesystem and cannot fail with a cross-device link error. +pub(crate) fn backup_path(target: &Path) -> PathBuf { + let name = target + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| "buffer".to_owned()); + let dir = target.parent().unwrap_or_else(|| Path::new(".")); + // The process id keeps concurrent instances from colliding without + // needing a random source. + dir.join(format!(".{name}.mojibake-{}.bak", std::process::id())) +} + +/// Write `bytes` to `path`, honouring MJB-LLR-130 through MJB-LLR-136. +/// +/// `force` corresponds to `:w!` and permits creating a missing parent +/// directory (MJB-LLR-132). +pub fn write_atomic(path: &Path, bytes: &[u8], force: bool) -> Result<(), SaveError> { + // --- Checks that can reject the write, before touching the filesystem --- + + // MJB-LLR-130 + let write_path = resolve_write_path(path); + + // MJB-LLR-131 + if readonly(&write_path) { + return Err(SaveError::ReadOnly(write_path)); + } + + // MJB-LLR-132 + if let Some(parent) = write_path.parent() + && !parent.as_os_str().is_empty() + && !parent.exists() + { + if force { + fs::create_dir_all(parent)?; + } else { + return Err(SaveError::NoParent(parent.to_path_buf())); + } + } + + // --- Backup (MJB-LLR-133, MJB-LLR-134) --- + + let exists = write_path.exists(); + let copy_mode = exists && must_copy(&write_path); + let backup = if exists { + let backup = backup_path(&write_path); + let made = if copy_mode { + fs::copy(&write_path, &backup).map(|_| ()) + } else { + fs::rename(&write_path, &backup) + }; + // A backup we could not make is not fatal; the write proceeds without + // the safety net rather than refusing to save at all. + match made { + Ok(()) => Some(backup), + Err(_) => None, + } + } else { + None + }; + + // --- The write itself --- + + let result = (|| -> io::Result<()> { + let mut file = fs::File::create(&write_path)?; + file.write_all(bytes)?; + file.sync_all()?; + Ok(()) + })(); + + match (result, backup) { + (Ok(()), Some(backup)) => { + // MJB-LLR-136 + let _ = copy_permissions(&backup, &write_path); + let _ = fs::remove_file(&backup); + Ok(()) + } + (Ok(()), None) => Ok(()), + (Err(e), Some(backup)) => { + // MJB-LLR-135: put the original back. + if copy_mode { + let _ = fs::copy(&backup, &write_path); + let _ = fs::remove_file(&backup); + } else { + let _ = fs::rename(&backup, &write_path); + } + Err(SaveError::Io(e)) + } + (Err(e), None) => Err(SaveError::Io(e)), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tmp() -> tempfile::TempDir { + tempfile::tempdir().unwrap() + } + + #[test] + fn writes_a_new_file() { + let d = tmp(); + let p = d.path().join("new.txt"); + write_atomic(&p, b"hello", false).unwrap(); + assert_eq!(fs::read(&p).unwrap(), b"hello"); + } + + #[test] + fn overwrites_an_existing_file() { + let d = tmp(); + let p = d.path().join("f.txt"); + fs::write(&p, b"old contents that are longer").unwrap(); + write_atomic(&p, b"new", false).unwrap(); + assert_eq!(fs::read(&p).unwrap(), b"new"); + } + + #[test] + fn mjb_llr_136_no_backup_file_is_left_behind() { + let d = tmp(); + let p = d.path().join("f.txt"); + fs::write(&p, b"old").unwrap(); + write_atomic(&p, b"new", false).unwrap(); + + let leftovers: Vec<_> = fs::read_dir(d.path()) + .unwrap() + .filter_map(Result::ok) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.contains("mojibake")) + .collect(); + assert!(leftovers.is_empty(), "stray backups: {leftovers:?}"); + } + + #[test] + fn mjb_llr_132_missing_parent_is_refused_without_force() { + let d = tmp(); + let p = d.path().join("missing").join("f.txt"); + let err = write_atomic(&p, b"x", false).unwrap_err(); + assert!(matches!(err, SaveError::NoParent(_))); + assert!(!p.exists()); + } + + #[test] + fn mjb_llr_132_force_creates_the_parent() { + let d = tmp(); + let p = d.path().join("a").join("b").join("f.txt"); + write_atomic(&p, b"x", true).unwrap(); + assert_eq!(fs::read(&p).unwrap(), b"x"); + } + + #[cfg(unix)] + #[test] + fn mjb_llr_131_readonly_target_is_refused() { + use std::os::unix::fs::PermissionsExt; + + let d = tmp(); + let p = d.path().join("ro.txt"); + fs::write(&p, b"original").unwrap(); + fs::set_permissions(&p, fs::Permissions::from_mode(0o444)).unwrap(); + + let err = write_atomic(&p, b"replacement", false).unwrap_err(); + assert!(matches!(err, SaveError::ReadOnly(_))); + assert_eq!( + fs::read(&p).unwrap(), + b"original", + "a refused write must not truncate the file" + ); + } + + /// MJB-LLR-130, MJB-LLR-133: the link must survive and its target change. + #[cfg(unix)] + #[test] + fn mjb_llr_130_write_follows_symlink_without_replacing_it() { + let d = tmp(); + let target = d.path().join("target.txt"); + let link = d.path().join("link.txt"); + fs::write(&target, b"before").unwrap(); + std::os::unix::fs::symlink(&target, &link).unwrap(); + + write_atomic(&link, b"after", false).unwrap(); + + assert!( + fs::symlink_metadata(&link).unwrap().file_type().is_symlink(), + "the symlink must still be a symlink" + ); + assert_eq!(fs::read(&target).unwrap(), b"after", "target updated"); + } + + /// MJB-LLR-133: a hardlinked file must keep its link count. + #[cfg(unix)] + #[test] + fn mjb_llr_133_hardlink_is_detected_and_preserved() { + let d = tmp(); + let a = d.path().join("a.txt"); + let b = d.path().join("b.txt"); + fs::write(&a, b"before").unwrap(); + fs::hard_link(&a, &b).unwrap(); + + assert!(must_copy(&a), "hardlinked file must use copy mode"); + + write_atomic(&a, b"after", false).unwrap(); + assert_eq!(fs::read(&a).unwrap(), b"after"); + assert_eq!( + fs::read(&b).unwrap(), + b"after", + "the hard link must still point at the same inode" + ); + } + + #[cfg(unix)] + #[test] + fn mjb_llr_130_relative_symlink_resolves_against_its_own_directory() { + let d = tmp(); + let target = d.path().join("t.txt"); + let link = d.path().join("l.txt"); + fs::write(&target, b"x").unwrap(); + std::os::unix::fs::symlink("t.txt", &link).unwrap(); + + assert_eq!(resolve_write_path(&link), target); + } + + #[test] + fn mjb_llr_131_missing_file_is_not_readonly() { + let d = tmp(); + assert!( + !readonly(&d.path().join("does-not-exist")), + "a creatable path must not be reported read-only" + ); + } + + /// MJB-LLR-134: the backup must live in the target's own directory. A + /// backup in a temp dir elsewhere would make the rename cross a filesystem + /// boundary and fail with EXDEV. + #[test] + fn mjb_llr_134_backup_is_created_beside_the_target() { + let target = Path::new("/some/deep/directory/file.txt"); + let backup = backup_path(target); + assert_eq!( + backup.parent(), + target.parent(), + "backup must sit beside the target, not in a temp directory" + ); + assert_ne!(backup, target); + assert!( + backup + .file_name() + .unwrap() + .to_string_lossy() + .starts_with('.'), + "backup should be hidden" + ); + } + + #[test] + fn mjb_llr_134_backup_path_handles_a_bare_file_name() { + // No parent component: must not panic. + let backup = backup_path(Path::new("file.txt")); + assert!(backup.to_string_lossy().contains("file.txt")); + } + + #[test] + fn mjb_llr_133_plain_file_does_not_need_copy_mode() { + let d = tmp(); + let p = d.path().join("plain.txt"); + fs::write(&p, b"x").unwrap(); + assert!(!must_copy(&p)); + } + + /// MJB-LLR-135: when the write cannot even be created, the original + /// contents must still be on disk afterwards. + #[cfg(unix)] + #[test] + fn mjb_llr_135_failed_write_restores_the_original() { + use std::os::unix::fs::PermissionsExt; + + let d = tmp(); + let sub = d.path().join("sub"); + fs::create_dir(&sub).unwrap(); + let p = sub.join("f.txt"); + fs::write(&p, b"original").unwrap(); + + // Make the *directory* unwritable so File::create fails after the + // backup has been taken. The file itself stays writable, so the + // read-only pre-check does not short-circuit the test. + fs::set_permissions(&sub, fs::Permissions::from_mode(0o500)).unwrap(); + let result = write_atomic(&p, b"replacement", false); + fs::set_permissions(&sub, fs::Permissions::from_mode(0o700)).unwrap(); + + if result.is_err() { + assert_eq!( + fs::read(&p).unwrap(), + b"original", + "a failed write must restore the previous contents" + ); + } + // Running as root defeats the permission bits; the assertion above is + // skipped in that case rather than reporting a false failure. + } +} diff --git a/src/buffer/selection.rs b/src/buffer/selection.rs new file mode 100644 index 0000000..e30d617 --- /dev/null +++ b/src/buffer/selection.rs @@ -0,0 +1,344 @@ +//! Selection model — byte-indexed, following Helix's `helix-core/src/selection.rs`. +//! +//! This is what makes the editor selection-first rather than Vim-like: a motion +//! leaves a *range*, and an operator such as `d` acts on that range. There is no +//! operator-pending state anywhere in the editor. +//! +//! Conventions, preserved exactly from Helix: +//! +//! - A range is **half-open**: inclusive of `from()`, exclusive of `to()`, +//! regardless of whether `head` precedes or follows `anchor`. +//! - The visible block cursor spans one grapheme *inward* from the head, so a +//! forward range `0..1` shows its cursor on byte 0, not byte 1. +//! +//! Per MJB-LLR-009 a `Selection` holds exactly one range. It is a struct rather +//! than a bare `Range` so that multi-cursor support can be added later without +//! reworking call sites. + +use ropey::RopeSlice; + +use super::grapheme::{next_grapheme_boundary, prev_grapheme_boundary}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Direction { + Forward, + Backward, +} + +/// MJB-LLR-001: a range over the buffer, both offsets in **bytes**. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct Range { + /// The side that stays put when extending. + pub anchor: usize, + /// The side that moves when extending. + pub head: usize, +} + +impl Range { + pub fn new(anchor: usize, head: usize) -> Self { + Self { anchor, head } + } + + /// A zero-width range at `head`. + pub fn point(head: usize) -> Self { + Self { anchor: head, head } + } + + /// MJB-LLR-011: clamp both offsets into the buffer and snap them to char + /// boundaries. Byte indexing permits offsets that char indexing could not + /// express, and ropey panics on them — see MJB-DR-002. + pub fn clamped(self, text: RopeSlice) -> Self { + let len = text.len(); + Self { + anchor: text.floor_char_boundary(self.anchor.min(len)), + head: text.floor_char_boundary(self.head.min(len)), + } + } + + /// MJB-LLR-002: lower bound, inclusive. + pub fn from(&self) -> usize { + self.anchor.min(self.head) + } + + /// MJB-LLR-002: upper bound, exclusive. + pub fn to(&self) -> usize { + self.anchor.max(self.head) + } + + /// MJB-LLR-003 + pub fn is_empty(&self) -> bool { + self.anchor == self.head + } + + /// Byte length of the span. + pub fn len(&self) -> usize { + self.to() - self.from() + } + + /// MJB-LLR-004 + pub fn direction(&self) -> Direction { + if self.head < self.anchor { + Direction::Backward + } else { + Direction::Forward + } + } + + /// MJB-LLR-005: the byte offset the block cursor is drawn at. + /// + /// For a forward range the head sits *past* the last selected grapheme, so + /// the cursor steps back one grapheme to land on it. + pub fn cursor(&self, text: RopeSlice) -> usize { + if self.head > self.anchor { + prev_grapheme_boundary(text, self.head) + } else { + self.head + } + } + + /// MJB-LLR-006, MJB-LLR-007: move the cursor to `byte_idx`. + /// + /// Without `extend` this collapses to a point. With `extend` the anchor is + /// nudged by one grapheme when the range flips direction across it, so the + /// anchored grapheme stays selected — this is Helix's `put_cursor`. + pub fn put_cursor(self, text: RopeSlice, byte_idx: usize, extend: bool) -> Self { + if !extend { + return Range::point(byte_idx).clamped(text); + } + + let anchor = if self.head >= self.anchor && byte_idx < self.anchor { + next_grapheme_boundary(text, self.anchor) + } else if self.head < self.anchor && byte_idx >= self.anchor { + prev_grapheme_boundary(text, self.anchor) + } else { + self.anchor + }; + + if anchor <= byte_idx { + Range::new(anchor, next_grapheme_boundary(text, byte_idx)).clamped(text) + } else { + Range::new(anchor, byte_idx).clamped(text) + } + } + + /// The line the cursor lies on. + pub fn cursor_line(&self, text: RopeSlice) -> usize { + text.byte_to_line_idx(self.cursor(text), super::LINE_TYPE) + } + + /// MJB-LLR-008: inclusive span of line indices the range covers. + pub fn line_range(&self, text: RopeSlice) -> (usize, usize) { + let lt = super::LINE_TYPE; + let start = text.byte_to_line_idx(self.from(), lt); + // An exclusive upper bound sitting exactly on a line start belongs to + // the previous line, otherwise `x` on a full line would report two. + let end_byte = if self.to() > self.from() { + self.to() - 1 + } else { + self.to() + }; + let end = text.byte_to_line_idx(end_byte.min(text.len()), lt); + (start, end) + } + + /// Flip anchor and head, keeping the same span. + pub fn flipped(self) -> Self { + Range::new(self.head, self.anchor) + } +} + +/// MJB-LLR-009: exactly one range, with `primary_index` pinned at zero. +/// +/// The vector and index exist so the multi-cursor shape is already in place; +/// the invariant is asserted, not assumed. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Selection { + ranges: Vec<Range>, + primary_index: usize, +} + +impl Default for Selection { + fn default() -> Self { + Self::point(0) + } +} + +impl Selection { + pub fn single(range: Range) -> Self { + Self { + ranges: vec![range], + primary_index: 0, + } + } + + pub fn point(byte_idx: usize) -> Self { + Self::single(Range::point(byte_idx)) + } + + /// MJB-LLR-010 + pub fn primary(&self) -> Range { + self.ranges[self.primary_index] + } + + pub fn set_primary(&mut self, range: Range) { + self.ranges[self.primary_index] = range; + } + + pub fn ranges(&self) -> &[Range] { + &self.ranges + } + + /// MJB-LLR-009: the single-range invariant, checked rather than assumed. + pub fn invariant_holds(&self) -> bool { + self.ranges.len() == 1 && self.primary_index == 0 + } + + /// Clamp every range into `text`. + pub fn clamped(mut self, text: RopeSlice) -> Self { + for r in &mut self.ranges { + *r = r.clamped(text); + } + self + } +} + +#[cfg(test)] +mod tests { + use ropey::Rope; + + use super::*; + + /// MJB-LLR-001: offsets are bytes, not characters. A range over a + /// multi-byte character must report its byte extent. + #[test] + fn mjb_llr_001_offsets_are_byte_indices() { + let r = Rope::from_str("文字"); + let s = r.slice(..); + assert_eq!(s.len(), 6, "two 3-byte characters"); + + let whole = Range::new(0, 6).clamped(s); + assert_eq!(whole.len(), 6, "length is in bytes, not characters"); + + // A single character spans three byte offsets. + let first = Range::new(0, 3).clamped(s); + assert_eq!(first.len(), 3); + } + + #[test] + fn mjb_llr_002_from_and_to_ignore_direction() { + assert_eq!(Range::new(2, 5).from(), 2); + assert_eq!(Range::new(2, 5).to(), 5); + assert_eq!(Range::new(5, 2).from(), 2, "backward range still orders"); + assert_eq!(Range::new(5, 2).to(), 5); + } + + #[test] + fn mjb_llr_003_is_empty() { + assert!(Range::point(3).is_empty()); + assert!(!Range::new(3, 4).is_empty()); + } + + #[test] + fn mjb_llr_004_direction() { + assert_eq!(Range::new(1, 5).direction(), Direction::Forward); + assert_eq!(Range::new(5, 1).direction(), Direction::Backward); + assert_eq!( + Range::point(2).direction(), + Direction::Forward, + "an empty range is forward by convention" + ); + } + + #[test] + fn mjb_llr_005_cursor_steps_back_on_forward_range() { + let r = Rope::from_str("abcdef"); + let s = r.slice(..); + // Forward 0..1 selects byte 0, so the cursor is drawn on byte 0. + assert_eq!(Range::new(0, 1).cursor(s), 0); + assert_eq!(Range::new(0, 3).cursor(s), 2); + // A backward range's head already sits on the cursor. + assert_eq!(Range::new(3, 0).cursor(s), 0); + assert_eq!(Range::point(4).cursor(s), 4); + } + + #[test] + fn mjb_llr_005_cursor_respects_grapheme_clusters() { + let r = Rope::from_str("文字"); + let s = r.slice(..); + // Head past the first wide char: cursor lands on its start, not mid-char. + assert_eq!(Range::new(0, 3).cursor(s), 0); + } + + #[test] + fn mjb_llr_006_put_cursor_without_extend_collapses() { + let r = Rope::from_str("abcdef"); + let s = r.slice(..); + let got = Range::new(0, 4).put_cursor(s, 2, false); + assert_eq!(got, Range::point(2)); + } + + #[test] + fn mjb_llr_007_put_cursor_with_extend_keeps_anchor() { + let r = Rope::from_str("abcdef"); + let s = r.slice(..); + let got = Range::new(1, 2).put_cursor(s, 4, true); + assert_eq!(got.anchor, 1, "anchor stays put when extending forward"); + assert_eq!(got.head, 5, "head lands one grapheme past the target"); + } + + #[test] + fn mjb_llr_007_put_cursor_extend_flips_direction() { + let r = Rope::from_str("abcdef"); + let s = r.slice(..); + // Forward range extended to before its anchor must flip and nudge the + // anchor forward one grapheme so the anchored byte stays selected. + let got = Range::new(2, 4).put_cursor(s, 0, true); + assert_eq!(got.direction(), Direction::Backward); + assert_eq!(got.anchor, 3); + assert_eq!(got.head, 0); + } + + #[test] + fn mjb_llr_011_clamped_snaps_into_bounds_and_onto_char_boundary() { + let r = Rope::from_str("文"); + let s = r.slice(..); + assert_eq!(Range::new(0, 99).clamped(s).head, 3, "clamped to length"); + assert_eq!( + Range::new(0, 1).clamped(s).head, + 0, + "an offset inside a multi-byte char snaps back to its start" + ); + } + + #[test] + fn mjb_llr_008_line_range() { + let r = Rope::from_str("aa\nbb\ncc\n"); + let s = r.slice(..); + assert_eq!(Range::point(0).line_range(s), (0, 0)); + // Exactly one full line, terminator included, is still one line. + assert_eq!(Range::new(0, 3).line_range(s), (0, 0)); + assert_eq!(Range::new(0, 6).line_range(s), (0, 1)); + } + + #[test] + fn mjb_llr_009_selection_invariant() { + let sel = Selection::point(0); + assert!(sel.invariant_holds()); + assert_eq!(sel.ranges().len(), 1); + } + + #[test] + fn mjb_llr_010_primary_round_trips() { + let mut sel = Selection::point(0); + sel.set_primary(Range::new(1, 4)); + assert_eq!(sel.primary(), Range::new(1, 4)); + } + + #[test] + fn flipped_preserves_span() { + let r = Range::new(2, 7).flipped(); + assert_eq!((r.anchor, r.head), (7, 2)); + assert_eq!(r.from(), 2); + assert_eq!(r.to(), 7); + } +} diff --git a/src/buffer/transaction.rs b/src/buffer/transaction.rs new file mode 100644 index 0000000..b58bed9 --- /dev/null +++ b/src/buffer/transaction.rs @@ -0,0 +1,424 @@ +//! Change sets and transactions — after Helix's `helix-core/src/transaction.rs`. +//! +//! Every buffer modification is expressed as a [`Transaction`]. Undo is not a +//! separate mechanism: it is the *inverse* transaction, computed against the +//! document as it stood before the change (MJB-HLR-011). +//! +//! All counts are **byte** lengths. + +use ropey::Rope; + +use super::selection::Selection; + +/// MJB-LLR-040 +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Operation { + /// Leave `n` bytes untouched. + Retain(usize), + /// Remove `n` bytes. + Delete(usize), + /// Insert this text. + Insert(String), +} + +impl Operation { + /// Bytes of the *pre-application* document this operation consumes. + fn consumed(&self) -> usize { + match self { + Operation::Retain(n) | Operation::Delete(n) => *n, + Operation::Insert(_) => 0, + } + } + + /// Bytes this operation contributes to the *post-application* document. + fn produced(&self) -> usize { + match self { + Operation::Retain(n) => *n, + Operation::Delete(_) => 0, + Operation::Insert(s) => s.len(), + } + } +} + +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum ChangeError { + #[error("change set expects a document of {expected} bytes, got {actual}")] + LengthMismatch { expected: usize, actual: usize }, + #[error("operation boundary at byte {0} is not a character boundary")] + NonCharBoundary(usize), + #[error("operation at byte {0} extends past the end of the document")] + OutOfBounds(usize), +} + +/// MJB-LLR-041 +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct ChangeSet { + changes: Vec<Operation>, + /// Document length this change set requires before application. + len: usize, + /// Document length after application. + len_after: usize, +} + +impl ChangeSet { + pub fn new(rope: &Rope) -> Self { + let len = rope.len(); + Self { + changes: Vec::new(), + len, + len_after: len, + } + } + + pub fn from_ops(ops: Vec<Operation>) -> Self { + let len = ops.iter().map(Operation::consumed).sum(); + let len_after = ops.iter().map(Operation::produced).sum(); + Self { + changes: ops, + len, + len_after, + } + } + + pub fn ops(&self) -> &[Operation] { + &self.changes + } + + pub fn len(&self) -> usize { + self.len + } + + pub fn len_after(&self) -> usize { + self.len_after + } + + pub fn is_empty(&self) -> bool { + self.changes + .iter() + .all(|op| matches!(op, Operation::Retain(_))) + } + + /// MJB-LLR-042, MJB-LLR-043, MJB-LLR-044: apply to `rope`. + /// + /// Validation runs to completion *before* any mutation, so a rejected + /// change set leaves the rope untouched rather than half-applied. + pub fn apply(&self, rope: &mut Rope) -> Result<(), ChangeError> { + // MJB-LLR-042 + if rope.len() != self.len { + return Err(ChangeError::LengthMismatch { + expected: self.len, + actual: rope.len(), + }); + } + + // MJB-LLR-044: verify every boundary first. ropey panics on a non-char + // boundary, and a panic is not an acceptable failure mode (MJB-DR-002). + let mut probe = 0usize; + for op in &self.changes { + if !rope.is_char_boundary(probe) { + return Err(ChangeError::NonCharBoundary(probe)); + } + probe += op.consumed(); + if probe > rope.len() { + return Err(ChangeError::OutOfBounds(probe)); + } + } + if !rope.is_char_boundary(probe) { + return Err(ChangeError::NonCharBoundary(probe)); + } + + // MJB-LLR-043: apply front-to-back in a single pass. + // + // `pos` tracks the cursor in the *output*, which is what makes this + // work without buffering the edits or walking backwards: `Retain` + // advances over text present in both images, `Delete` removes at `pos` + // and so leaves it pointing at the next surviving byte, and `Insert` + // advances past what it added. Later offsets therefore stay valid as + // the rope shifts beneath them. + let mut pos = 0usize; + for op in &self.changes { + match op { + Operation::Retain(n) => pos += n, + Operation::Delete(n) => rope.remove(pos..pos + n), + Operation::Insert(s) => { + rope.insert(pos, s); + pos += s.len(); + } + } + } + + debug_assert_eq!(rope.len(), self.len_after); + Ok(()) + } + + /// MJB-LLR-045: the change set that undoes this one. + /// + /// MJB-LLR-046: applying this change set and then its inverse reproduces + /// the original contents exactly — the property undo rests on. + /// + /// `original` must be the document as it stood *before* this change set was + /// applied — deleted text is recovered from it. + pub fn invert(&self, original: &Rope) -> ChangeSet { + let mut ops = Vec::with_capacity(self.changes.len()); + let mut pos = 0usize; + + for op in &self.changes { + match op { + Operation::Retain(n) => { + ops.push(Operation::Retain(*n)); + pos += n; + } + Operation::Delete(n) => { + let text: String = original.slice(pos..pos + n).chunks().collect(); + ops.push(Operation::Insert(text)); + pos += n; + } + Operation::Insert(s) => ops.push(Operation::Delete(s.len())), + } + } + + ChangeSet { + changes: ops, + len: self.len_after, + len_after: self.len, + } + } +} + +/// MJB-LLR-047: a change set plus the selection that should result from it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Transaction { + pub changes: ChangeSet, + pub selection: Option<Selection>, +} + +impl Transaction { + pub fn new(changes: ChangeSet) -> Self { + Self { + changes, + selection: None, + } + } + + pub fn with_selection(mut self, selection: Selection) -> Self { + self.selection = Some(selection); + self + } + + /// MJB-LLR-048: build from `(from, to, Option<text>)` triples, which must + /// arrive ordered by ascending `from` and must not overlap. + pub fn change<I>(rope: &Rope, changes: I) -> Self + where + I: IntoIterator<Item = (usize, usize, Option<String>)>, + { + let mut ops = Vec::new(); + let mut pos = 0usize; + + for (from, to, text) in changes { + if from > pos { + ops.push(Operation::Retain(from - pos)); + } + if to > from { + ops.push(Operation::Delete(to - from)); + } + if let Some(s) = text + && !s.is_empty() + { + ops.push(Operation::Insert(s)); + } + pos = to.max(from); + } + + let len = rope.len(); + if pos < len { + ops.push(Operation::Retain(len - pos)); + } + + Self::new(ChangeSet::from_ops(ops)) + } + + /// MJB-LLR-049: insert `text` at the selection's cursor. + pub fn insert(rope: &Rope, selection: &Selection, text: &str) -> Self { + let at = selection.primary().cursor(rope.slice(..)); + Self::change(rope, [(at, at, Some(text.to_owned()))]) + } + + /// MJB-LLR-050: delete the selection's primary span. + pub fn delete(rope: &Rope, selection: &Selection) -> Self { + let r = selection.primary(); + Self::change(rope, [(r.from(), r.to(), None)]) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn rope(s: &str) -> Rope { + Rope::from_str(s) + } + + /// MJB-LLR-040: operation counts are byte lengths, so a multi-byte + /// character contributes its byte width. + #[test] + fn mjb_llr_040_operation_counts_are_byte_lengths() { + let insert = Operation::Insert("文".to_owned()); + assert_eq!(insert.produced(), 3, "one character, three bytes"); + assert_eq!(insert.consumed(), 0, "an insert consumes no input"); + + assert_eq!(Operation::Retain(4).consumed(), 4); + assert_eq!(Operation::Retain(4).produced(), 4); + assert_eq!(Operation::Delete(4).consumed(), 4); + assert_eq!(Operation::Delete(4).produced(), 0); + } + + /// MJB-LLR-041: `len` is the required pre-image length, `len_after` the + /// post-image length. + #[test] + fn mjb_llr_041_changeset_records_both_lengths() { + let r = rope("abcdef"); + // Replace two bytes with three. + let t = Transaction::change(&r, [(1, 3, Some("XYZ".into()))]); + assert_eq!(t.changes.len(), 6, "must match the source document"); + assert_eq!(t.changes.len_after(), 7, "6 - 2 + 3"); + + let mut m = r.clone(); + t.changes.apply(&mut m).unwrap(); + assert_eq!(m.len(), t.changes.len_after()); + } + + #[test] + fn mjb_llr_041_empty_changeset_reports_equal_lengths() { + let r = rope("abc"); + let cs = ChangeSet::new(&r); + assert_eq!(cs.len(), 3); + assert_eq!(cs.len_after(), 3); + assert!(cs.is_empty()); + } + + /// MJB-LLR-047: a transaction carries the selection that should result. + #[test] + fn mjb_llr_047_transaction_carries_a_selection() { + use super::super::selection::Range; + let r = rope("abcdef"); + let t = Transaction::change(&r, [(0, 1, None)]); + assert_eq!(t.selection, None, "none by default"); + + let sel = Selection::single(Range::new(0, 2)); + let t = t.with_selection(sel.clone()); + assert_eq!(t.selection, Some(sel)); + } + + #[test] + fn mjb_llr_043_apply_insert_and_delete() { + let mut r = rope("hello world"); + let t = Transaction::change(&r, [(0, 5, Some("goodbye".into()))]); + t.changes.apply(&mut r).unwrap(); + assert_eq!(r.to_string(), "goodbye world"); + } + + #[test] + fn mjb_llr_042_length_mismatch_is_rejected() { + let r = rope("abcdef"); + let t = Transaction::change(&r, [(0, 1, None)]); + let mut other = rope("shorter than expected? no — different"); + let err = t.changes.apply(&mut other).unwrap_err(); + assert!(matches!(err, ChangeError::LengthMismatch { .. })); + } + + #[test] + fn mjb_llr_042_rejected_change_leaves_rope_untouched() { + let r = rope("abcdef"); + let t = Transaction::change(&r, [(0, 3, Some("xyz".into()))]); + let mut other = rope("12345678"); + let before = other.to_string(); + assert!(t.changes.apply(&mut other).is_err()); + assert_eq!(other.to_string(), before, "must not partially apply"); + } + + #[test] + fn mjb_llr_044_non_char_boundary_errors_rather_than_panics() { + // Split "文" (3 bytes) after its first byte. + let mut r = rope("文"); + let cs = ChangeSet::from_ops(vec![Operation::Retain(1), Operation::Delete(2)]); + let err = cs.apply(&mut r).unwrap_err(); + assert_eq!(err, ChangeError::NonCharBoundary(1)); + assert_eq!(r.to_string(), "文", "rope must be unchanged"); + } + + #[test] + fn mjb_llr_045_invert_maps_each_operation() { + let r = rope("abcdef"); + let t = Transaction::change(&r, [(1, 3, Some("XY".into()))]); + let inv = t.changes.invert(&r); + // Delete(2) became Insert("bc"); Insert("XY") became Delete(2). + assert!(inv.ops().contains(&Operation::Insert("bc".into()))); + assert!(inv.ops().contains(&Operation::Delete(2))); + } + + #[test] + fn mjb_llr_046_apply_then_invert_round_trips() { + for (text, from, to, ins) in [ + ("hello world", 0usize, 5usize, Some("goodbye")), + ("hello world", 5, 11, None), + ("", 0, 0, Some("new")), + ("文字化け", 0, 3, Some("X")), + ("no trailing newline", 3, 3, Some(" inserted")), + ] { + let original = rope(text); + let mut r = original.clone(); + let t = Transaction::change(&r, [(from, to, ins.map(str::to_owned))]); + let inverse = t.changes.invert(&original); + + t.changes.apply(&mut r).unwrap(); + inverse.apply(&mut r).unwrap(); + + assert_eq!( + r.to_string(), + original.to_string(), + "round trip failed for {text:?}" + ); + } + } + + #[test] + fn mjb_llr_048_multiple_ordered_changes() { + let mut r = rope("aaa bbb ccc"); + let t = Transaction::change(&r, [(0, 3, Some("XXX".into())), (8, 11, Some("ZZZ".into()))]); + t.changes.apply(&mut r).unwrap(); + assert_eq!(r.to_string(), "XXX bbb ZZZ"); + } + + #[test] + fn mjb_llr_049_insert_at_cursor() { + let r = rope("ab"); + let sel = Selection::point(1); + let mut m = r.clone(); + Transaction::insert(&r, &sel, "X") + .changes + .apply(&mut m) + .unwrap(); + assert_eq!(m.to_string(), "aXb"); + } + + #[test] + fn mjb_llr_050_delete_selection_span() { + use super::super::selection::Range; + let r = rope("abcdef"); + let sel = Selection::single(Range::new(1, 4)); + let mut m = r.clone(); + Transaction::delete(&r, &sel) + .changes + .apply(&mut m) + .unwrap(); + assert_eq!(m.to_string(), "aef"); + } + + #[test] + fn empty_document_accepts_insert() { + let mut r = rope(""); + let t = Transaction::change(&r, [(0, 0, Some("x".into()))]); + t.changes.apply(&mut r).unwrap(); + assert_eq!(r.to_string(), "x"); + } +} diff --git a/src/buffer/view.rs b/src/buffer/view.rs new file mode 100644 index 0000000..e419052 --- /dev/null +++ b/src/buffer/view.rs @@ -0,0 +1,371 @@ +//! Viewport — after Helix's `helix-view/src/view.rs`. +//! +//! The pagination requirement (MJB-HLR-012) is met structurally, not by +//! optimisation: the viewport is anchored by the **byte offset of the first +//! visible line**, and rendering walks `lines_at(top_line)` for at most +//! `height` lines. Nothing in this module iterates the whole rope, so per-frame +//! cost is O(viewport) whatever the file size. +//! +//! Helix's `ViewPosition` also carries a `vertical_offset` addressing rows +//! within a soft-wrapped line. There is no soft wrap here, so one buffer line +//! is exactly one screen row and the field is omitted — see MJB-DR-003. + +use ropey::RopeSlice; + +use super::{ + LINE_TYPE, + grapheme::display_column, + movement::last_content_line, + selection::Range, +}; + +/// MJB-LLR-090 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct ViewPosition { + /// Byte offset of the first visible line's start. Always a line start. + pub anchor: usize, + /// Leftmost visible display column. + pub horizontal_offset: usize, +} + +#[derive(Debug, Clone, Copy, Default)] +pub struct View { + pub offset: ViewPosition, +} + +impl View { + pub fn new() -> Self { + Self::default() + } + + /// MJB-LLR-091 + pub fn top_line(&self, text: RopeSlice) -> usize { + let anchor = self.offset.anchor.min(text.len()); + text.byte_to_line_idx(anchor, LINE_TYPE) + } + + /// MJB-LLR-092: the visible line range, `(first, count)`. + /// + /// Deliberately returns indices rather than content so the caller can drive + /// `lines_at` directly and touch no other line. + pub fn visible_line_range(&self, text: RopeSlice, height: usize) -> (usize, usize) { + let top = self.top_line(text); + let total = text.len_lines(LINE_TYPE); + let count = height.min(total.saturating_sub(top)); + (top, count) + } + + /// MJB-LLR-092: at most `height` line slices, starting at the top line. + pub fn visible_lines<'a>( + &self, + text: RopeSlice<'a>, + height: usize, + ) -> impl Iterator<Item = RopeSlice<'a>> { + let (top, count) = self.visible_line_range(text, height); + text.lines_at(top, LINE_TYPE).take(count) + } + + /// Set the top line directly, clamping into the buffer (MJB-LLR-097). + pub fn set_top_line(&mut self, text: RopeSlice, line: usize) { + let last = text.len_lines(LINE_TYPE).saturating_sub(1); + let line = line.min(last); + self.offset.anchor = text.line_to_byte_idx(line, LINE_TYPE); + } + + /// MJB-LLR-093..098: scroll vertically so the cursor sits inside the + /// scroll-off margins. + pub fn ensure_cursor_in_view( + &mut self, + text: RopeSlice, + range: Range, + height: usize, + scrolloff: usize, + ) { + // MJB-LLR-098: a zero-height viewport has no inside; the margin + // arithmetic below would underflow. + if height == 0 { + return; + } + + // MJB-LLR-093: Helix clamps the margins to half the viewport, so a + // scrolloff larger than the viewport cannot fight itself. + let scrolloff_top = scrolloff.min((height - 1) / 2); + let scrolloff_bottom = scrolloff.min(height / 2); + + let cursor_line = range.cursor_line(text); + let top = self.top_line(text); + + let new_top = if cursor_line < top + scrolloff_top { + // MJB-LLR-094 + Some(cursor_line.saturating_sub(scrolloff_top)) + } else if cursor_line + scrolloff_bottom >= top + height { + // MJB-LLR-095 + Some((cursor_line + scrolloff_bottom + 1).saturating_sub(height)) + } else { + // MJB-LLR-096 + None + }; + + if let Some(t) = new_top { + self.set_top_line(text, t); + } + } + + /// MJB-LLR-099: scroll horizontally so the cursor's column is visible. + pub fn ensure_horizontal_in_view(&mut self, text: RopeSlice, range: Range, width: usize) { + if width == 0 { + return; + } + let cursor = range.cursor(text); + let line = text.byte_to_line_idx(cursor, LINE_TYPE); + let line_start = text.line_to_byte_idx(line, LINE_TYPE); + let column = display_column(text.line(line, LINE_TYPE), cursor - line_start); + + if column < self.offset.horizontal_offset { + self.offset.horizontal_offset = column; + } else if column >= self.offset.horizontal_offset + width { + self.offset.horizontal_offset = column + 1 - width; + } + } + + /// MJB-LLR-100, MJB-LLR-101, MJB-LLR-102: move cursor and viewport together + /// by `lines`, saturating at the buffer's ends. + pub fn page(&mut self, text: RopeSlice, range: Range, lines: usize, down: bool) -> Range { + let last = last_content_line(text); + let cursor_line = range.cursor_line(text); + let top = self.top_line(text); + + let (target_line, new_top) = if down { + ( + cursor_line.saturating_add(lines).min(last), + top.saturating_add(lines), + ) + } else { + ( + cursor_line.saturating_sub(lines), + top.saturating_sub(lines), + ) + }; + + self.set_top_line(text, new_top); + + // Land on the same display column where the target line allows it. + let line_start = text.line_to_byte_idx(cursor_line, LINE_TYPE); + let column = display_column(text.line(cursor_line, LINE_TYPE), range.cursor(text) - line_start); + let target_start = text.line_to_byte_idx(target_line, LINE_TYPE); + let offset = + super::grapheme::byte_at_display_column(text.line(target_line, LINE_TYPE), column); + + Range::point(target_start + offset).clamped(text) + } +} + +#[cfg(test)] +mod tests { + use ropey::Rope; + + use super::*; + + /// 100 lines: "line0\nline1\n...". + fn doc(n: usize) -> Rope { + let mut s = String::new(); + for i in 0..n { + s.push_str(&format!("line{i}\n")); + } + Rope::from_str(&s) + } + + fn at_line(text: RopeSlice, line: usize) -> Range { + Range::point(text.line_to_byte_idx(line, LINE_TYPE)) + } + + /// MJB-LLR-090: the anchor is a **byte** offset and always a line start. + #[test] + fn mjb_llr_090_anchor_is_a_byte_offset_at_a_line_start() { + // Multi-byte lines, so a byte anchor differs from a line index. + let t = Rope::from_str("文字\n化け\n三行\n"); + let s = t.slice(..); + let mut v = View::new(); + + v.set_top_line(s, 1); + assert_eq!(v.offset.anchor, 7, "byte offset, not line index"); + assert_eq!( + v.offset.anchor, + s.line_to_byte_idx(1, LINE_TYPE), + "anchor must land exactly on a line start" + ); + assert_eq!(v.top_line(s), 1, "and convert back"); + + assert_eq!(v.offset.horizontal_offset, 0, "columns start unscrolled"); + } + + #[test] + fn mjb_llr_091_top_line_from_anchor() { + let t = doc(10); + let s = t.slice(..); + let mut v = View::new(); + v.set_top_line(s, 4); + assert_eq!(v.top_line(s), 4); + } + + /// MJB-LLR-092: the renderer must see exactly the visible window. + #[test] + fn mjb_llr_092_visible_lines_are_bounded_by_height() { + let t = doc(100); + let s = t.slice(..); + let mut v = View::new(); + v.set_top_line(s, 10); + + let lines: Vec<String> = v.visible_lines(s, 5).map(|l| l.to_string()).collect(); + assert_eq!(lines.len(), 5, "must not exceed the viewport height"); + assert_eq!(lines[0], "line10\n"); + assert_eq!(lines[4], "line14\n"); + } + + #[test] + fn mjb_llr_092_visible_lines_clamp_near_end_of_buffer() { + let t = doc(10); + let s = t.slice(..); + let mut v = View::new(); + v.set_top_line(s, 9); + // 10 content lines plus the trailing empty line ropey reports. + let count = v.visible_lines(s, 20).count(); + assert!(count <= 2, "must not run past the end, got {count}"); + } + + #[test] + fn mjb_llr_096_no_scroll_when_cursor_is_comfortable() { + let t = doc(100); + let s = t.slice(..); + let mut v = View::new(); + v.set_top_line(s, 10); + let before = v.offset; + v.ensure_cursor_in_view(s, at_line(s, 15), 20, 5); + assert_eq!(v.offset, before, "cursor already inside both margins"); + } + + #[test] + fn mjb_llr_094_scrolls_up_to_honour_top_margin() { + let t = doc(100); + let s = t.slice(..); + let mut v = View::new(); + v.set_top_line(s, 20); + v.ensure_cursor_in_view(s, at_line(s, 21), 20, 5); + assert_eq!(v.top_line(s), 16, "cursor_line - scrolloff_top"); + } + + #[test] + fn mjb_llr_095_scrolls_down_to_honour_bottom_margin() { + let t = doc(100); + let s = t.slice(..); + let mut v = View::new(); + v.set_top_line(s, 0); + // height 20, scrolloff 5 -> cursor at 18 forces top to 18+5+1-20 = 4. + v.ensure_cursor_in_view(s, at_line(s, 18), 20, 5); + assert_eq!(v.top_line(s), 4); + } + + /// MJB-LLR-093: scrolloff exceeding the viewport must be clamped, not + /// allowed to drive the anchor past the cursor. + #[test] + fn mjb_llr_093_scrolloff_larger_than_viewport_is_clamped() { + let t = doc(100); + let s = t.slice(..); + let mut v = View::new(); + v.set_top_line(s, 50); + v.ensure_cursor_in_view(s, at_line(s, 50), 10, 999); + // Margins clamp to (10-1)/2 = 4 and 10/2 = 5. + assert_eq!(v.top_line(s), 46); + } + + /// MJB-LLR-098: a zero-height viewport must not underflow `height - 1`. + #[test] + fn mjb_llr_098_zero_height_viewport_is_a_noop() { + let t = doc(10); + let s = t.slice(..); + let mut v = View::new(); + v.set_top_line(s, 3); + let before = v.offset; + v.ensure_cursor_in_view(s, at_line(s, 9), 0, 5); + assert_eq!(v.offset, before); + } + + #[test] + fn mjb_llr_097_top_line_clamps_into_buffer() { + let t = doc(10); + let s = t.slice(..); + let mut v = View::new(); + v.set_top_line(s, 9_999); + assert!(v.top_line(s) < t.len_lines(LINE_TYPE)); + } + + #[test] + fn mjb_llr_094_scroll_near_start_saturates_at_zero() { + let t = doc(100); + let s = t.slice(..); + let mut v = View::new(); + v.set_top_line(s, 2); + v.ensure_cursor_in_view(s, at_line(s, 0), 20, 5); + assert_eq!(v.top_line(s), 0, "must not underflow below line zero"); + } + + #[test] + fn mjb_llr_099_horizontal_scroll_follows_cursor() { + let t = Rope::from_str(&format!("{}\n", "x".repeat(200))); + let s = t.slice(..); + let mut v = View::new(); + v.ensure_horizontal_in_view(s, Range::point(150), 80); + assert_eq!(v.offset.horizontal_offset, 150 + 1 - 80); + + v.ensure_horizontal_in_view(s, Range::point(10), 80); + assert_eq!(v.offset.horizontal_offset, 10, "scrolls back left"); + } + + #[test] + fn mjb_llr_100_half_page_moves_cursor_and_view() { + let t = doc(100); + let s = t.slice(..); + let mut v = View::new(); + v.set_top_line(s, 0); + let moved = v.page(s, at_line(s, 0), 10, true); + assert_eq!(moved.cursor_line(s), 10, "cursor moved"); + assert_eq!(v.top_line(s), 10, "and the viewport moved with it"); + } + + #[test] + fn mjb_llr_101_full_page_moves_by_height() { + let t = doc(100); + let s = t.slice(..); + let mut v = View::new(); + v.set_top_line(s, 0); + let moved = v.page(s, at_line(s, 0), 20, true); + assert_eq!(moved.cursor_line(s), 20); + assert_eq!(v.top_line(s), 20); + } + + #[test] + fn mjb_llr_102_paging_saturates_at_both_ends() { + let t = doc(10); + let s = t.slice(..); + let mut v = View::new(); + + let up = v.page(s, at_line(s, 0), 50, false); + assert_eq!(up.cursor_line(s), 0, "must not underflow"); + assert_eq!(v.top_line(s), 0); + + let down = v.page(s, at_line(s, 0), 500, true); + assert!( + down.cursor_line(s) <= last_content_line(s), + "must not run past the last content line" + ); + } + + #[test] + fn mjb_llr_092_empty_buffer_renders_safely() { + let t = Rope::from_str(""); + let s = t.slice(..); + let v = View::new(); + assert_eq!(v.top_line(s), 0); + let _ = v.visible_lines(s, 10).count(); + } +} diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..9b50bcb --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,54 @@ +use std::path::PathBuf; + +use clap::Parser; + +use crate::config::{get_config_dir, get_data_dir}; + +#[derive(Parser, Debug)] +#[command(author, version = version(), about)] +pub struct Cli { + /// File to edit. A path that does not exist is created on write. + // MJB-LLR-111, MJB-LLR-112 + #[arg(value_name = "FILE")] + pub file: Option<PathBuf>, + + /// Tick rate, i.e. number of ticks per second + #[arg(short, long, value_name = "FLOAT", default_value_t = 4.0)] + pub tick_rate: f64, + + /// Frame rate, i.e. number of frames per second + #[arg(short, long, value_name = "FLOAT", default_value_t = 60.0)] + pub frame_rate: f64, +} + +const VERSION_MESSAGE: &str = concat!( + env!("CARGO_PKG_VERSION"), + "-", + env!("VERGEN_GIT_DESCRIBE"), + " (", + env!("VERGEN_BUILD_DATE"), + ")" +); + +/// The `origin` remote of the checkout this binary was built from, emitted by +/// `build.rs`. +const GIT_REMOTE_URL: &str = env!("VERGEN_GIT_REMOTE_URL"); + +pub fn version() -> String { + let author = clap::crate_authors!(); + + // let current_exe_path = PathBuf::from(clap::crate_name!()).display().to_string(); + let config_dir_path = get_config_dir().display().to_string(); + let data_dir_path = get_data_dir().display().to_string(); + + format!( + "\ +{VERSION_MESSAGE} + +Authors: {author} + +Repository: {GIT_REMOTE_URL} +Config directory: {config_dir_path} +Data directory: {data_dir_path}" + ) +} diff --git a/src/components.rs b/src/components.rs new file mode 100644 index 0000000..1771a17 --- /dev/null +++ b/src/components.rs @@ -0,0 +1,125 @@ +use crossterm::event::{KeyEvent, MouseEvent}; +use ratatui::{ + Frame, + layout::{Rect, Size}, +}; +use tokio::sync::mpsc::UnboundedSender; + +use crate::{action::Action, config::Config, tui::Event}; + +// MJB-LLR-204, MJB-HLR-019: the buffer is the only widget. The template's +// `fps` and `home` modules were deleted, not merely unregistered. +pub mod buffer; + +/// `Component` is a trait that represents a visual and interactive element of the user interface. +/// +/// Implementors of this trait can be registered with the main application loop and will be able to +/// receive events, update state, and be rendered on the screen. +pub trait Component { + /// Register an action handler that can send actions for processing if necessary. + /// + /// # Arguments + /// + /// * `tx` - An unbounded sender that can send actions. + /// + /// # Returns + /// + /// * [`color_eyre::Result<()>`] - An Ok result or an error. + fn register_action_handler(&mut self, tx: UnboundedSender<Action>) -> color_eyre::Result<()> { + let _ = tx; // to appease clippy + Ok(()) + } + /// Register a configuration handler that provides configuration settings if necessary. + /// + /// # Arguments + /// + /// * `config` - Configuration settings. + /// + /// # Returns + /// + /// * [`color_eyre::Result<()>`] - An Ok result or an error. + fn register_config_handler(&mut self, config: Config) -> color_eyre::Result<()> { + let _ = config; // to appease clippy + Ok(()) + } + /// Initialize the component with a specified area if necessary. + /// + /// # Arguments + /// + /// * `area` - Rectangular area to initialize the component within. + /// + /// # Returns + /// + /// * [`color_eyre::Result<()>`] - An Ok result or an error. + fn init(&mut self, area: Size) -> color_eyre::Result<()> { + let _ = area; // to appease clippy + Ok(()) + } + /// Handle incoming events and produce actions if necessary. + /// + /// # Arguments + /// + /// * `event` - An optional event to be processed. + /// + /// # Returns + /// + /// * [`color_eyre::Result<Option<Action>>`] - An action to be processed or none. + fn handle_events(&mut self, event: Option<Event>) -> color_eyre::Result<Option<Action>> { + let action = match event { + Some(Event::Key(key_event)) => self.handle_key_event(key_event)?, + Some(Event::Mouse(mouse_event)) => self.handle_mouse_event(mouse_event)?, + _ => None, + }; + Ok(action) + } + /// Handle key events and produce actions if necessary. + /// + /// # Arguments + /// + /// * `key` - A key event to be processed. + /// + /// # Returns + /// + /// * [`color_eyre::Result<Option<Action>>`] - An action to be processed or none. + fn handle_key_event(&mut self, key: KeyEvent) -> color_eyre::Result<Option<Action>> { + let _ = key; // to appease clippy + Ok(None) + } + /// Handle mouse events and produce actions if necessary. + /// + /// # Arguments + /// + /// * `mouse` - A mouse event to be processed. + /// + /// # Returns + /// + /// * [`color_eyre::Result<Option<Action>>`] - An action to be processed or none. + fn handle_mouse_event(&mut self, mouse: MouseEvent) -> color_eyre::Result<Option<Action>> { + let _ = mouse; // to appease clippy + Ok(None) + } + /// Update the state of the component based on a received action. (REQUIRED) + /// + /// # Arguments + /// + /// * `action` - An action that may modify the state of the component. + /// + /// # Returns + /// + /// * [`color_eyre::Result<Option<Action>>`] - An action to be processed or none. + fn update(&mut self, action: Action) -> color_eyre::Result<Option<Action>> { + let _ = action; // to appease clippy + Ok(None) + } + /// Render the component on the screen. (REQUIRED) + /// + /// # Arguments + /// + /// * `f` - A frame used for rendering. + /// * `area` - The area in which the component should be drawn. + /// + /// # Returns + /// + /// * [`color_eyre::Result<()>`] - An Ok result or an error. + fn draw(&mut self, frame: &mut Frame, area: Rect) -> color_eyre::Result<()>; +} diff --git a/src/components/buffer.rs b/src/components/buffer.rs new file mode 100644 index 0000000..360ef7c --- /dev/null +++ b/src/components/buffer.rs @@ -0,0 +1,237 @@ +//! The buffer widget — the only widget mojibake presents (MJB-HLR-019). +//! +//! Rendering is deliberately thin: it asks [`View::visible_lines`] for at most +//! `height` line slices and draws those. Nothing here walks the document, so +//! per-frame cost is O(viewport) however large the file (MJB-LLR-200). + +use std::path::PathBuf; + +use color_eyre::eyre::eyre; +use crossterm::event::KeyEvent; +use ratatui::{ + Frame, + layout::{Constraint, Layout, Rect}, + style::{Style, Stylize}, + text::{Line, Span}, + widgets::Paragraph, +}; +use tokio::sync::mpsc::UnboundedSender; + +use super::Component; +use crate::{ + action::Action, + buffer::{ + Buffer, LINE_TYPE, Outcome, + grapheme::{display_column, grapheme_str, grapheme_width, next_grapheme_boundary}, + }, + config::{Config, Mode}, +}; + +pub struct BufferComponent { + buffer: Buffer, + command_tx: Option<UnboundedSender<Action>>, +} + +impl BufferComponent { + pub fn new(config: Config, path: Option<PathBuf>) -> color_eyre::Result<Self> { + let buffer = Buffer::new(config, path).map_err(|e| eyre!("{e}"))?; + Ok(Self { + buffer, + command_tx: None, + }) + } + + pub fn buffer(&self) -> &Buffer { + &self.buffer + } + + /// Width of the line-number gutter, sized to the largest line number. + /// + /// Counts digits arithmetically rather than formatting the number, since + /// this runs every frame. `u16` cannot truncate here in practice — it would + /// take more than 10^65000 lines — but the conversion is still checked + /// rather than cast, and saturates to the terminal's own maximum width. + fn gutter_width(&self) -> u16 { + let lines = self.buffer.document.len_lines().max(1); + let digits = lines.ilog10() as usize + 1; + u16::try_from(digits + 1).unwrap_or(u16::MAX) // digits + one space of padding + } + + /// MJB-LLR-202: mode, path, modified marker and cursor position. + fn status_line(&self) -> String { + let doc = &self.buffer.document; + let name = doc + .path() + .map(|p| p.display().to_string()) + .unwrap_or_else(|| "[scratch]".to_owned()); + let modified = if doc.is_modified() { " [+]" } else { "" }; + + let text = doc.slice(); + let cursor = doc.range().cursor(text); + let line = text.byte_to_line_idx(cursor, LINE_TYPE); + let line_start = text.line_to_byte_idx(line, LINE_TYPE); + let col = display_column(text.line(line, LINE_TYPE), cursor - line_start); + + format!( + " {} {name}{modified} {}:{} ", + self.buffer.mode, + line + 1, + col + 1 + ) + } + + /// The bottom row: the command line while in command mode, otherwise any + /// transient message, otherwise the pending key sequence. + fn message_line(&self) -> String { + if self.buffer.mode == Mode::Command { + return format!(":{}", self.buffer.command_line); + } + if let Some(msg) = &self.buffer.status { + return msg.clone(); + } + if !self.buffer.pending_keys().is_empty() { + let keys: String = self + .buffer + .pending_keys() + .iter() + .map(crate::config::key_event_to_string) + .collect(); + return keys; + } + String::new() + } +} + +impl Component for BufferComponent { + fn register_action_handler(&mut self, tx: UnboundedSender<Action>) -> color_eyre::Result<()> { + self.command_tx = Some(tx); + Ok(()) + } + + fn register_config_handler(&mut self, _config: Config) -> color_eyre::Result<()> { + // The config is supplied at construction, because the document must be + // opened with it; re-registering would discard buffer state. + Ok(()) + } + + fn handle_key_event(&mut self, key: KeyEvent) -> color_eyre::Result<Option<Action>> { + Ok(match self.buffer.handle_key(key) { + Outcome::Consumed => None, + Outcome::Quit => Some(Action::Quit), + Outcome::Suspend => Some(Action::Suspend), + }) + } + + fn draw(&mut self, frame: &mut Frame, area: Rect) -> color_eyre::Result<()> { + // MJB-LLR-202: reserve the last two rows for status and message. + let [text_area, status_area, message_area] = Layout::vertical([ + Constraint::Min(0), + Constraint::Length(1), + Constraint::Length(1), + ]) + .areas(area); + + let gutter = self.gutter_width(); + let [gutter_area, content_area] = + Layout::horizontal([Constraint::Length(gutter), Constraint::Min(0)]).areas(text_area); + + let height = content_area.height as usize; + let width = content_area.width as usize; + self.buffer.update_view(width, height); + + let mode = self.buffer.mode; + let cfg = self.buffer.config(); + let cursor_style = cfg.style(mode, "cursor"); + let selection_style = cfg.style(mode, "selection"); + let linenr_style = cfg.style(mode, "linenr"); + let status_style = cfg.style(mode, "statusline"); + + let doc = &self.buffer.document; + let text = doc.slice(); + let range = doc.range(); + let cursor_byte = range.cursor(text); + let sel_from = range.from(); + let sel_to = range.to(); + + let (top_line, count) = self.buffer.view.visible_line_range(text, height); + let h_offset = self.buffer.view.offset.horizontal_offset; + + let mut rows: Vec<Line> = Vec::with_capacity(count); + let mut gutter_rows: Vec<Line> = Vec::with_capacity(count); + + // MJB-LLR-200: only the visible lines are touched. + for (i, line) in self.buffer.view.visible_lines(text, height).enumerate() { + let line_idx = top_line + i; + let line_start = text.line_to_byte_idx(line_idx, LINE_TYPE); + + gutter_rows.push(Line::from(Span::styled( + format!("{:>w$} ", line_idx + 1, w = (gutter as usize).saturating_sub(1)), + linenr_style, + ))); + + // MJB-LLR-201: style the cursor and the selection span. + let mut spans: Vec<Span> = Vec::new(); + let mut column = 0usize; + let mut byte = 0usize; + let line_len = line.len(); + + while byte < line_len { + let next = next_grapheme_boundary(line, byte); + if next <= byte { + break; + } + // Borrows from the rope unless the grapheme straddles a chunk. + let g = grapheme_str(line, byte..next); + if matches!(g.as_ref(), "\n" | "\r\n" | "\r") { + break; + } + + let abs = line_start + byte; + let w = grapheme_width(&g, column); + + let style = if abs == cursor_byte { + cursor_style + } else if abs >= sel_from && abs < sel_to { + selection_style + } else { + Style::default() + }; + + // Horizontal scrolling: skip graphemes left of the offset. + if column + w > h_offset { + // A tab is stored as one byte but occupies `w` columns. + let rendered = if g == "\t" { + " ".repeat(w) + } else { + g.into_owned() + }; + spans.push(Span::styled(rendered, style)); + } + + column += w; + byte = next; + } + + // A cursor sitting past the last character (end of line, or an + // empty line) still needs a visible block. + if cursor_byte == line_start + byte && cursor_byte >= line_start { + spans.push(Span::styled(" ", cursor_style)); + } + + rows.push(Line::from(spans)); + } + + frame.render_widget(Paragraph::new(gutter_rows), gutter_area); + frame.render_widget(Paragraph::new(rows), content_area); + frame.render_widget( + Paragraph::new(Line::from(Span::styled(self.status_line(), status_style))), + status_area, + ); + frame.render_widget( + Paragraph::new(Line::from(self.message_line())).dim(), + message_area, + ); + + Ok(()) + } +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..328d1d6 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,888 @@ +use std::{collections::HashMap, env, path::PathBuf, sync::LazyLock}; + +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use directories::ProjectDirs; +use ratatui::style::{Color, Modifier, Style}; +use serde::{Deserialize, Serialize, de::Deserializer}; +use tracing::error; + +use crate::buffer::command::Command; + +/// MJB-LLR-180: the compiled-in default configuration is the very file the +/// editor also reads at runtime, so defaults and documentation cannot drift. +const CONFIG: &str = include_str!("../.config/config.toml"); + +/// Editor mode. Doubles as the scope key for both key bindings and styles. +/// +/// MJB-LLR-184. `Global` is consulted only by `App`, never by the buffer; see +/// MJB-DR-004 for why keymap ownership is split. +#[derive(Default, Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Mode { + #[default] + Normal, + Insert, + Select, + Command, + Global, +} + +impl std::fmt::Display for Mode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let name = match self { + Mode::Normal => "NOR", + Mode::Insert => "INS", + Mode::Select => "SEL", + Mode::Command => "CMD", + Mode::Global => "GLB", + }; + f.write_str(name) + } +} + +#[derive(Clone, Debug, Deserialize, Default)] +pub struct AppConfig { + #[serde(default)] + pub data_dir: PathBuf, + #[serde(default)] + pub config_dir: PathBuf, +} + +/// MJB-LLR-185. Kept in its own table rather than at top level: `Config` +/// flattens `AppConfig`, and config-rs stringifies values buffered through a +/// flattened map, which would break these non-string fields. +#[derive(Clone, Copy, Debug, Deserialize)] +pub struct EditorConfig { + #[serde(default = "default_scrolloff")] + pub scrolloff: usize, + #[serde(default = "default_insert_final_newline")] + pub insert_final_newline: bool, +} + +fn default_scrolloff() -> usize { + 5 +} + +fn default_insert_final_newline() -> bool { + true +} + +impl Default for EditorConfig { + fn default() -> Self { + Self { + scrolloff: default_scrolloff(), + insert_final_newline: default_insert_final_newline(), + } + } +} + +#[derive(Clone, Debug, Default, Deserialize)] +pub struct Config { + #[serde(default, flatten)] + pub config: AppConfig, + #[serde(default)] + pub editor: EditorConfig, + #[serde(default)] + pub keybindings: KeyBindings, + #[serde(default)] + pub styles: Styles, +} + +/// The application's own name, independent of the Cargo package name. +/// +/// Stated explicitly rather than derived from `CARGO_PKG_NAME`, which is +/// `mojibake-editor` (the crate name on crates.io), or from +/// `CARGO_CRATE_NAME`, which differs between the `mojibake` library target and +/// the `moji` binary target. Deriving from either would silently move the +/// user's configuration directory and log file when a target is renamed. +pub const APP_NAME: &str = "mojibake"; + +pub static PROJECT_NAME: LazyLock<String> = LazyLock::new(|| APP_NAME.to_uppercase()); +pub static DATA_FOLDER: LazyLock<Option<PathBuf>> = LazyLock::new(|| { + env::var(format!("{}_DATA", PROJECT_NAME.clone())) + .ok() + .map(PathBuf::from) +}); +pub static CONFIG_FOLDER: LazyLock<Option<PathBuf>> = LazyLock::new(|| { + env::var(format!("{}_CONFIG", PROJECT_NAME.clone())) + .ok() + .map(PathBuf::from) +}); + +impl Config { + pub fn new() -> color_eyre::Result<Self, config::ConfigError> { + // MJB-LLR-180. A malformed baked-in default is a build defect, but it + // must still surface as an error rather than a panic (MJB-HLR-018). + let default_config: Config = toml::from_str(CONFIG).map_err(|e| { + config::ConfigError::Message(format!("built-in default config is invalid: {e}")) + })?; + + let data_dir = get_data_dir(); + let config_dir = get_config_dir(); + let mut builder = config::Config::builder() + .set_default("data_dir", path_to_setting(&data_dir)?)? + .set_default("config_dir", path_to_setting(&config_dir)?)?; + + // MJB-LLR-181: TOML is the only format consulted. The json5/yaml/ini + // readers are not merely unused here — `default-features = false` in + // Cargo.toml keeps them out of the dependency graph entirely. + let config_file = config_dir.join("config.toml"); + if !config_file.exists() { + error!( + "No configuration file at {}; built-in defaults will be used", + config_file.display() + ); + } + builder = builder.add_source( + config::File::from(config_file) + .format(config::FileFormat::Toml) + .required(false), + ); + + let mut cfg: Self = builder.build()?.try_deserialize()?; + + // MJB-LLR-182: merge per individual binding, so a user who rebinds one + // key keeps every default they did not mention. + for (mode, default_bindings) in default_config.keybindings.0.iter() { + let user_bindings = cfg.keybindings.0.entry(*mode).or_default(); + for (key, cmd) in default_bindings.iter() { + user_bindings.entry(key.clone()).or_insert(*cmd); + } + } + for (mode, default_styles) in default_config.styles.0.iter() { + let user_styles = cfg.styles.0.entry(*mode).or_default(); + for (style_key, style) in default_styles.iter() { + user_styles.entry(style_key.clone()).or_insert(*style); + } + } + + Ok(cfg) + } + + /// The style named `key` for `mode`, or [`Style::default`] if unset. + pub fn style(&self, mode: Mode, key: &str) -> Style { + self.styles + .0 + .get(&mode) + .and_then(|m| m.get(key)) + .copied() + .unwrap_or_default() + } +} + +/// A path becomes a config setting only if it is valid UTF-8. Reported rather +/// than unwrapped, per MJB-HLR-018. +fn path_to_setting(path: &std::path::Path) -> color_eyre::Result<String, config::ConfigError> { + path.to_str().map(str::to_owned).ok_or_else(|| { + config::ConfigError::Message(format!("path is not valid UTF-8: {}", path.display())) + }) +} + +pub fn get_data_dir() -> PathBuf { + if let Some(s) = DATA_FOLDER.clone() { + s + } else if let Some(proj_dirs) = project_directory() { + proj_dirs.data_local_dir().to_path_buf() + } else { + PathBuf::from(".").join(".data") + } +} + +pub fn get_config_dir() -> PathBuf { + if let Some(s) = CONFIG_FOLDER.clone() { + s + } else if let Some(proj_dirs) = project_directory() { + proj_dirs.config_local_dir().to_path_buf() + } else { + PathBuf::from(".").join(".config") + } +} + +fn project_directory() -> Option<ProjectDirs> { + // The qualifier was the application template author's; mojibake owns its + // own directories. `APP_NAME`, not the package name — see its doc comment. + ProjectDirs::from("wiki", "mojibake", APP_NAME) +} + +#[derive(Clone, Debug, Default)] +pub struct KeyBindings(pub HashMap<Mode, HashMap<Vec<KeyEvent>, Command>>); + +impl<'de> Deserialize<'de> for KeyBindings { + fn deserialize<D>(deserializer: D) -> color_eyre::Result<Self, D::Error> + where + D: Deserializer<'de>, + { + let parsed_map = HashMap::<Mode, HashMap<String, Command>>::deserialize(deserializer)?; + + let mut keybindings = HashMap::new(); + for (mode, inner_map) in parsed_map { + let mut converted = HashMap::new(); + for (key_str, cmd) in inner_map { + // MJB-LLR-183: a bad key string names itself in the error + // rather than aborting the process. + let keys = parse_key_sequence(&key_str).map_err(|e| { + serde::de::Error::custom(format!( + "invalid key sequence {key_str:?} in [keybindings.{mode:?}]: {e}" + )) + })?; + converted.insert(keys, cmd); + } + keybindings.insert(mode, converted); + } + + Ok(KeyBindings(keybindings)) + } +} + +fn parse_key_event(raw: &str) -> color_eyre::Result<KeyEvent, String> { + // Modifier prefixes and named keys are matched case-insensitively, but the + // final token's case is significant and must survive: `<A>` and `<a>` are + // different bindings. Lowercasing the whole string collapsed them onto the + // same `KeyCode::Char('a')`, so every shifted binding silently shadowed its + // lowercase twin. + // + // `to_ascii_lowercase` preserves byte length, so an offset found in the + // lowercased copy indexes the original correctly. + let raw_lower = raw.to_ascii_lowercase(); + let (offset, modifiers) = extract_modifiers(&raw_lower); + parse_key_code_with_modifiers(&raw[offset..], &raw_lower[offset..], modifiers) +} + +/// Returns the byte offset past the modifier prefixes, and the modifiers found. +fn extract_modifiers(raw_lower: &str) -> (usize, KeyModifiers) { + let mut modifiers = KeyModifiers::empty(); + let mut offset = 0; + + loop { + let rest = &raw_lower[offset..]; + if rest.starts_with("ctrl-") { + modifiers.insert(KeyModifiers::CONTROL); + offset += 5; + } else if rest.starts_with("alt-") { + modifiers.insert(KeyModifiers::ALT); + offset += 4; + } else if rest.starts_with("shift-") { + modifiers.insert(KeyModifiers::SHIFT); + offset += 6; + } else { + break; + } + } + + (offset, modifiers) +} + +fn parse_key_code_with_modifiers( + raw: &str, + raw_lower: &str, + mut modifiers: KeyModifiers, +) -> color_eyre::Result<KeyEvent, String> { + let c = match raw_lower { + "esc" => KeyCode::Esc, + "enter" => KeyCode::Enter, + "left" => KeyCode::Left, + "right" => KeyCode::Right, + "up" => KeyCode::Up, + "down" => KeyCode::Down, + "home" => KeyCode::Home, + "end" => KeyCode::End, + "pageup" => KeyCode::PageUp, + "pagedown" => KeyCode::PageDown, + "backtab" => { + modifiers.insert(KeyModifiers::SHIFT); + KeyCode::BackTab + } + "backspace" => KeyCode::Backspace, + "delete" => KeyCode::Delete, + "insert" => KeyCode::Insert, + "f1" => KeyCode::F(1), + "f2" => KeyCode::F(2), + "f3" => KeyCode::F(3), + "f4" => KeyCode::F(4), + "f5" => KeyCode::F(5), + "f6" => KeyCode::F(6), + "f7" => KeyCode::F(7), + "f8" => KeyCode::F(8), + "f9" => KeyCode::F(9), + "f10" => KeyCode::F(10), + "f11" => KeyCode::F(11), + "f12" => KeyCode::F(12), + "space" => KeyCode::Char(' '), + "hyphen" => KeyCode::Char('-'), + "minus" => KeyCode::Char('-'), + "tab" => KeyCode::Tab, + _ if raw.chars().count() == 1 => { + // Case comes from the original token, not the lowercased copy. + let mut c = raw.chars().next().ok_or("empty key")?; + if modifiers.contains(KeyModifiers::SHIFT) { + c = c.to_ascii_uppercase(); + } else if c.is_ascii_uppercase() { + // crossterm reports a capital as Char('A') with SHIFT held, so + // `<A>` must produce exactly that to ever match. + modifiers.insert(KeyModifiers::SHIFT); + } + KeyCode::Char(c) + } + _ => return Err(format!("Unable to parse {raw}")), + }; + Ok(KeyEvent::new(c, modifiers)) +} + +pub fn key_event_to_string(key_event: &KeyEvent) -> String { + let char; + let key_code = match key_event.code { + KeyCode::Backspace => "backspace", + KeyCode::Enter => "enter", + KeyCode::Left => "left", + KeyCode::Right => "right", + KeyCode::Up => "up", + KeyCode::Down => "down", + KeyCode::Home => "home", + KeyCode::End => "end", + KeyCode::PageUp => "pageup", + KeyCode::PageDown => "pagedown", + KeyCode::Tab => "tab", + KeyCode::BackTab => "backtab", + KeyCode::Delete => "delete", + KeyCode::Insert => "insert", + KeyCode::F(c) => { + char = format!("f({c})"); + &char + } + KeyCode::Char(' ') => "space", + KeyCode::Char(c) => { + char = c.to_string(); + &char + } + KeyCode::Esc => "esc", + KeyCode::Null => "", + KeyCode::CapsLock => "", + KeyCode::Menu => "", + KeyCode::ScrollLock => "", + KeyCode::Media(_) => "", + KeyCode::NumLock => "", + KeyCode::PrintScreen => "", + KeyCode::Pause => "", + KeyCode::KeypadBegin => "", + KeyCode::Modifier(_) => "", + }; + + let mut modifiers = Vec::with_capacity(3); + + if key_event.modifiers.intersects(KeyModifiers::CONTROL) { + modifiers.push("ctrl"); + } + + if key_event.modifiers.intersects(KeyModifiers::SHIFT) { + modifiers.push("shift"); + } + + if key_event.modifiers.intersects(KeyModifiers::ALT) { + modifiers.push("alt"); + } + + let mut key = modifiers.join("-"); + + if !key.is_empty() { + key.push('-'); + } + key.push_str(key_code); + + key +} + +pub fn parse_key_sequence(raw: &str) -> color_eyre::Result<Vec<KeyEvent>, String> { + if raw.chars().filter(|c| *c == '>').count() != raw.chars().filter(|c| *c == '<').count() { + return Err(format!("Unable to parse `{}`", raw)); + } + let raw = if !raw.contains("><") { + let raw = raw.strip_prefix('<').unwrap_or(raw); + raw.strip_prefix('>').unwrap_or(raw) + } else { + raw + }; + let sequences = raw + .split("><") + .map(|seq| { + if let Some(s) = seq.strip_prefix('<') { + s + } else if let Some(s) = seq.strip_suffix('>') { + s + } else { + seq + } + }) + .collect::<Vec<_>>(); + + sequences.into_iter().map(parse_key_event).collect() +} + +#[derive(Clone, Debug, Default)] +pub struct Styles(pub HashMap<Mode, HashMap<String, Style>>); + +impl<'de> Deserialize<'de> for Styles { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: Deserializer<'de>, + { + let parsed_map = HashMap::<Mode, HashMap<String, String>>::deserialize(deserializer)?; + + let styles = parsed_map + .into_iter() + .map(|(mode, inner_map)| { + let converted_inner_map = inner_map + .into_iter() + .map(|(str, style)| (str, parse_style(&style))) + .collect(); + (mode, converted_inner_map) + }) + .collect(); + + Ok(Styles(styles)) + } +} + +pub fn parse_style(line: &str) -> Style { + let (foreground, background) = + line.split_at(line.to_lowercase().find("on ").unwrap_or(line.len())); + let foreground = process_color_string(foreground); + let background = process_color_string(&background.replace("on ", "")); + + let mut style = Style::default(); + if let Some(fg) = parse_color(&foreground.0) { + style = style.fg(fg); + } + if let Some(bg) = parse_color(&background.0) { + style = style.bg(bg); + } + style = style.add_modifier(foreground.1 | background.1); + style +} + +fn process_color_string(color_str: &str) -> (String, Modifier) { + let color = color_str + .replace("grey", "gray") + .replace("bright ", "") + .replace("bold ", "") + .replace("underline ", "") + .replace("inverse ", ""); + + let mut modifiers = Modifier::empty(); + if color_str.contains("underline") { + modifiers |= Modifier::UNDERLINED; + } + if color_str.contains("bold") { + modifiers |= Modifier::BOLD; + } + if color_str.contains("inverse") { + modifiers |= Modifier::REVERSED; + } + + (color, modifiers) +} + +fn parse_color(s: &str) -> Option<Color> { + let s = s.trim_start(); + let s = s.trim_end(); + // Every arm below must tolerate arbitrary user input: these strings come + // from `[styles]` in config.toml, and a panic here would take down the + // editor at startup (MJB-HLR-018). The original template code indexed + // `rgb` operands unchecked and added into `u8` unguarded, so `"rgb"`, + // `"rgb1"`, `"gray99"` and `"rgb999"` all aborted the process. + if s.contains("bright color") { + let c = s + .trim_start_matches("bright ") + .trim_start_matches("color") + .parse::<u8>() + .unwrap_or_default(); + // Bright ANSI colours are the base colour plus 8. The template wrote + // `wrapping_shl(8)`, which on a `u8` masks the shift to 8 % 8 == 0 and + // so returned the colour unchanged. + Some(Color::Indexed(c.saturating_add(8))) + } else if s.contains("color") { + let c = s + .trim_start_matches("color") + .parse::<u8>() + .unwrap_or_default(); + Some(Color::Indexed(c)) + } else if s.contains("gray") { + // The xterm grayscale ramp is 24 steps at indices 232..=255. + let step = s + .trim_start_matches("gray") + .parse::<u8>() + .unwrap_or_default() + .min(23); + Some(Color::Indexed(232 + step)) + } else if let Some(digits) = s.strip_prefix("rgb") { + // The xterm 216-colour cube at indices 16..=231: three components, + // each 0–5. `to_digit(6)` rejects anything outside that range, so the + // arithmetic below cannot exceed 231. + let mut components = digits.chars().map(|c| c.to_digit(6)); + match (components.next(), components.next(), components.next()) { + (Some(Some(r)), Some(Some(g)), Some(Some(b))) => { + let index = 16 + r * 36 + g * 6 + b; + debug_assert!(index <= 231); + Some(Color::Indexed(index as u8)) + } + _ => None, + } + } else if s == "bold black" { + Some(Color::Indexed(8)) + } else if s == "bold red" { + Some(Color::Indexed(9)) + } else if s == "bold green" { + Some(Color::Indexed(10)) + } else if s == "bold yellow" { + Some(Color::Indexed(11)) + } else if s == "bold blue" { + Some(Color::Indexed(12)) + } else if s == "bold magenta" { + Some(Color::Indexed(13)) + } else if s == "bold cyan" { + Some(Color::Indexed(14)) + } else if s == "bold white" { + Some(Color::Indexed(15)) + } else if s == "black" { + Some(Color::Indexed(0)) + } else if s == "red" { + Some(Color::Indexed(1)) + } else if s == "green" { + Some(Color::Indexed(2)) + } else if s == "yellow" { + Some(Color::Indexed(3)) + } else if s == "blue" { + Some(Color::Indexed(4)) + } else if s == "magenta" { + Some(Color::Indexed(5)) + } else if s == "cyan" { + Some(Color::Indexed(6)) + } else if s == "white" { + Some(Color::Indexed(7)) + } else { + None + } +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + + use super::*; + + #[test] + fn test_parse_style_default() { + let style = parse_style(""); + assert_eq!(style, Style::default()); + } + + #[test] + fn test_parse_style_foreground() { + let style = parse_style("red"); + assert_eq!(style.fg, Some(Color::Indexed(1))); + } + + #[test] + fn test_parse_style_background() { + let style = parse_style("on blue"); + assert_eq!(style.bg, Some(Color::Indexed(4))); + } + + #[test] + fn test_parse_style_modifiers() { + let style = parse_style("underline red on blue"); + assert_eq!(style.fg, Some(Color::Indexed(1))); + assert_eq!(style.bg, Some(Color::Indexed(4))); + } + + #[test] + fn test_process_color_string() { + let (color, modifiers) = process_color_string("underline bold inverse gray"); + assert_eq!(color, "gray"); + assert!(modifiers.contains(Modifier::UNDERLINED)); + assert!(modifiers.contains(Modifier::BOLD)); + assert!(modifiers.contains(Modifier::REVERSED)); + } + + #[test] + fn test_parse_color_rgb() { + let color = parse_color("rgb123"); + let expected = 16 + 36 + 2 * 6 + 3; + assert_eq!(color, Some(Color::Indexed(expected))); + } + + #[test] + fn test_parse_color_unknown() { + let color = parse_color("unknown"); + assert_eq!(color, None); + } + + /// MJB-HLR-018: colour strings come from user configuration, so no input + /// may abort the process. Regression guard — every case below panicked in + /// the template code this replaced, by unchecked indexing or by `u8` + /// overflow in a debug build. + #[test] + fn mjb_llr_183_malformed_colours_never_panic() { + for input in [ + "rgb", // indexed byte 3 of a 3-byte string + "rgb1", // indexed bytes 4 and 5 + "rgb12", // indexed byte 5 + "rgb999", // 16 + 9*36 + 9*6 + 9 = 403, overflows u8 + "rgb555", // the largest legal cube entry + "gray99", // 232 + 99 = 331, overflows u8 + "gray", // no digits at all + "color999", // does not fit u8 + "bright color999", + "", + "文字化け", // multi-byte: byte indexing would split a character + ] { + let _ = parse_color(input); + let _ = parse_style(input); + } + } + + #[test] + fn mjb_llr_183_colour_cube_bounds() { + // 216-colour cube occupies 16..=231. + assert_eq!(parse_color("rgb000"), Some(Color::Indexed(16))); + assert_eq!(parse_color("rgb555"), Some(Color::Indexed(231))); + // Components outside 0–5 are not cube coordinates. + assert_eq!(parse_color("rgb600"), None); + } + + #[test] + fn mjb_llr_183_grayscale_ramp_bounds() { + // Grayscale ramp occupies 232..=255. + assert_eq!(parse_color("gray0"), Some(Color::Indexed(232))); + assert_eq!(parse_color("gray23"), Some(Color::Indexed(255))); + assert_eq!( + parse_color("gray99"), + Some(Color::Indexed(255)), + "clamped to the end of the ramp rather than overflowing" + ); + } + + #[test] + fn mjb_llr_183_bright_colour_is_base_plus_eight() { + // The template's `wrapping_shl(8)` masked to a zero-bit shift, so + // bright colours were indistinguishable from their base. + assert_eq!(parse_color("bright color1"), Some(Color::Indexed(9))); + assert_ne!(parse_color("bright color1"), parse_color("color1")); + assert_eq!( + parse_color("bright color255"), + Some(Color::Indexed(255)), + "saturates instead of wrapping" + ); + } + + /// MJB-LLR-180: the compiled-in default TOML parses, and carries the + /// bindings the requirements mandate. + #[test] + fn mjb_llr_180_builtin_defaults_parse() { + let c: Config = toml::from_str(CONFIG).expect("built-in config.toml must parse"); + + let normal = c.keybindings.0.get(&Mode::Normal).expect("normal bindings"); + assert_eq!( + normal.get(&parse_key_sequence("<h>").unwrap()), + Some(&Command::MoveCharLeft) + ); + // A two-key sequence must survive parsing as two events. + let gg = parse_key_sequence("<g><g>").unwrap(); + assert_eq!(gg.len(), 2); + assert_eq!(normal.get(&gg), Some(&Command::GotoFileStart)); + + let global = c.keybindings.0.get(&Mode::Global).expect("global bindings"); + assert_eq!( + global.get(&parse_key_sequence("<ctrl-c>").unwrap()), + Some(&Command::Quit) + ); + } + + /// MJB-LLR-181: `config.toml` is the only file consulted. A config in any + /// other format sitting in the same directory must be ignored entirely. + #[test] + fn mjb_llr_181_only_config_toml_is_read() { + let dir = tempfile::tempdir().unwrap(); + + // Decoys in the formats the template used to accept. + std::fs::write( + dir.path().join("config.json5"), + "{ \"keybindings\": { \"normal\": { \"<q>\": \"Quit\" } } }", + ) + .unwrap(); + std::fs::write(dir.path().join("config.json"), "{\"editor\":{\"scrolloff\":99}}").unwrap(); + std::fs::write(dir.path().join("config.yaml"), "editor:\n scrolloff: 98\n").unwrap(); + std::fs::write(dir.path().join("config.ini"), "[editor]\nscrolloff=97\n").unwrap(); + std::fs::write(dir.path().join("config.toml"), "[editor]\nscrolloff = 7\n").unwrap(); + + let cfg: Config = config::Config::builder() + .add_source( + config::File::from(dir.path().join("config.toml")) + .format(config::FileFormat::Toml) + .required(false), + ) + .build() + .unwrap() + .try_deserialize() + .unwrap(); + + assert_eq!(cfg.editor.scrolloff, 7, "the TOML file must win"); + } + + /// MJB-LLR-185: editor settings deserialize with the documented defaults. + #[test] + fn mjb_llr_185_editor_defaults() { + let c: Config = toml::from_str(CONFIG).unwrap(); + assert_eq!(c.editor.scrolloff, 5); + assert!(c.editor.insert_final_newline); + + let empty: Config = toml::from_str("").unwrap(); + assert_eq!(empty.editor.scrolloff, 5); + assert!(empty.editor.insert_final_newline); + } + + /// MJB-LLR-182: a user binding overrides one default without disturbing + /// the rest of that mode. + #[test] + fn mjb_llr_182_user_bindings_merge_per_binding() { + let mut defaults: Config = toml::from_str(CONFIG).unwrap(); + let user: Config = + toml::from_str("[keybindings.normal]\n\"<h>\" = \"MoveCharRight\"\n").unwrap(); + + // Same merge direction as Config::new: user wins, defaults fill in. + let mut merged = user; + for (mode, default_bindings) in defaults.keybindings.0.drain() { + let entry = merged.keybindings.0.entry(mode).or_default(); + for (key, cmd) in default_bindings { + entry.entry(key).or_insert(cmd); + } + } + + let normal = merged.keybindings.0.get(&Mode::Normal).unwrap(); + assert_eq!( + normal.get(&parse_key_sequence("<h>").unwrap()), + Some(&Command::MoveCharRight), + "user binding must win" + ); + assert_eq!( + normal.get(&parse_key_sequence("<j>").unwrap()), + Some(&Command::MoveLineDown), + "untouched defaults must remain" + ); + } + + /// MJB-LLR-183: a malformed key sequence is a recoverable error naming the + /// offending string, not a panic. + #[test] + fn mjb_llr_183_invalid_keybinding_is_recoverable() { + let err = toml::from_str::<Config>("[keybindings.normal]\n\"<nonsense-key>\" = \"Undo\"\n") + .expect_err("must not deserialize"); + assert!( + err.to_string().contains("nonsense-key"), + "error must name the offending key, got: {err}" + ); + } + + /// MJB-LLR-184: modes deserialize from their lower-case names. + #[test] + fn mjb_llr_184_modes_deserialize_lowercase() { + let c: Config = toml::from_str( + "[keybindings.normal]\n\"<a>\" = \"Undo\"\n\ + [keybindings.insert]\n\"<b>\" = \"Undo\"\n\ + [keybindings.select]\n\"<c>\" = \"Undo\"\n\ + [keybindings.command]\n\"<d>\" = \"Undo\"\n\ + [keybindings.global]\n\"<e>\" = \"Undo\"\n", + ) + .unwrap(); + for mode in [ + Mode::Normal, + Mode::Insert, + Mode::Select, + Mode::Command, + Mode::Global, + ] { + assert!(c.keybindings.0.contains_key(&mode), "missing {mode:?}"); + } + } + + #[test] + fn test_simple_keys() { + assert_eq!( + parse_key_event("a").unwrap(), + KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty()) + ); + + assert_eq!( + parse_key_event("enter").unwrap(), + KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()) + ); + + assert_eq!( + parse_key_event("esc").unwrap(), + KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()) + ); + } + + #[test] + fn test_with_modifiers() { + assert_eq!( + parse_key_event("ctrl-a").unwrap(), + KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL) + ); + + assert_eq!( + parse_key_event("alt-enter").unwrap(), + KeyEvent::new(KeyCode::Enter, KeyModifiers::ALT) + ); + + assert_eq!( + parse_key_event("shift-esc").unwrap(), + KeyEvent::new(KeyCode::Esc, KeyModifiers::SHIFT) + ); + } + + #[test] + fn test_multiple_modifiers() { + assert_eq!( + parse_key_event("ctrl-alt-a").unwrap(), + KeyEvent::new( + KeyCode::Char('a'), + KeyModifiers::CONTROL | KeyModifiers::ALT + ) + ); + + assert_eq!( + parse_key_event("ctrl-shift-enter").unwrap(), + KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL | KeyModifiers::SHIFT) + ); + } + + #[test] + fn test_reverse_multiple_modifiers() { + assert_eq!( + key_event_to_string(&KeyEvent::new( + KeyCode::Char('a'), + KeyModifiers::CONTROL | KeyModifiers::ALT + )), + "ctrl-alt-a".to_string() + ); + } + + #[test] + fn test_invalid_keys() { + assert!(parse_key_event("invalid-key").is_err()); + assert!(parse_key_event("ctrl-invalid-key").is_err()); + } + + #[test] + fn test_case_insensitivity() { + assert_eq!( + parse_key_event("CTRL-a").unwrap(), + KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL) + ); + + assert_eq!( + parse_key_event("AlT-eNtEr").unwrap(), + KeyEvent::new(KeyCode::Enter, KeyModifiers::ALT) + ); + } +} diff --git a/src/errors.rs b/src/errors.rs new file mode 100644 index 0000000..ebed8af --- /dev/null +++ b/src/errors.rs @@ -0,0 +1,77 @@ +use std::env; + +use tracing::error; + +pub fn init() -> color_eyre::Result<()> { + let (panic_hook, eyre_hook) = color_eyre::config::HookBuilder::default() + .panic_section(format!( + "This is a bug. Consider reporting it at {}", + env!("CARGO_PKG_REPOSITORY") + )) + .capture_span_trace_by_default(false) + .display_location_section(false) + .display_env_section(false) + .into_hooks(); + eyre_hook.install()?; + std::panic::set_hook(Box::new(move |panic_info| { + if let Ok(mut t) = crate::tui::Tui::new() + && let Err(r) = t.exit() + { + error!("Unable to exit Terminal: {:?}", r); + } + + #[cfg(not(debug_assertions))] + { + use human_panic::{handle_dump, metadata, print_msg}; + let metadata = metadata!(); + let file_path = handle_dump(&metadata, panic_info); + // prints human-panic message + print_msg(file_path, &metadata) + .expect("human-panic: printing error message to console failed"); + eprintln!("{}", panic_hook.panic_report(panic_info)); // prints color-eyre stack trace to stderr + } + let msg = format!("{}", panic_hook.panic_report(panic_info)); + error!("Error: {}", strip_ansi_escapes::strip_str(msg)); + + #[cfg(debug_assertions)] + { + // Better Panic stacktrace that is only enabled when debugging. + better_panic::Settings::auto() + .most_recent_first(false) + .lineno_suffix(true) + .verbosity(better_panic::Verbosity::Full) + .create_panic_handler()(panic_info); + } + + std::process::exit(libc::EXIT_FAILURE); + })); + Ok(()) +} + +/// Similar to the `std::dbg!` macro, but generates `tracing` events rather +/// than printing to stdout. +/// +/// By default, the verbosity level for the generated events is `DEBUG`, but +/// this can be customized. +#[macro_export] +macro_rules! trace_dbg { + (target: $target:expr, level: $level:expr, $ex:expr) => { + { + match $ex { + value => { + tracing::event!(target: $target, $level, ?value, stringify!($ex)); + value + } + } + } + }; + (level: $level:expr, $ex:expr) => { + trace_dbg!(target: module_path!(), level: $level, $ex) + }; + (target: $target:expr, $ex:expr) => { + trace_dbg!(target: $target, level: tracing::Level::DEBUG, $ex) + }; + ($ex:expr) => { + trace_dbg!(level: tracing::Level::DEBUG, $ex) + }; +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..da70baa --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,18 @@ +//! 文字化け — a terminal text editor. +//! +//! The crate is split into a library and a thin binary so that the buffer core +//! under [`buffer`] can be exercised by requirements-based tests without a +//! terminal, and so structural coverage can be scoped to it. +//! +//! Developed to DO-178C DAL-C. Requirements live in `docs/requirements/`; +//! items implementing a low-level requirement carry a `MJB-LLR-nnn` comment. + +pub mod action; +pub mod app; +pub mod buffer; +pub mod cli; +pub mod components; +pub mod config; +pub mod errors; +pub mod logging; +pub mod tui; diff --git a/src/logging.rs b/src/logging.rs new file mode 100644 index 0000000..fd4e0b6 --- /dev/null +++ b/src/logging.rs @@ -0,0 +1,36 @@ +use std::sync::LazyLock; + +use tracing_error::ErrorLayer; +use tracing_subscriber::{EnvFilter, fmt, prelude::*}; + +use crate::config; + +pub static LOG_ENV: LazyLock<String> = + LazyLock::new(|| format!("{}_LOG_LEVEL", config::PROJECT_NAME.clone())); +pub static LOG_FILE: LazyLock<String> = LazyLock::new(|| format!("{}.log", config::APP_NAME)); + +pub fn init() -> color_eyre::Result<()> { + let directory = config::get_data_dir(); + std::fs::create_dir_all(directory.clone())?; + let log_path = directory.join(LOG_FILE.clone()); + let log_file = std::fs::File::create(log_path)?; + let env_filter = EnvFilter::builder().with_default_directive(tracing::Level::INFO.into()); + // If the `RUST_LOG` environment variable is set, use that as the default, otherwise use the + // value of the `LOG_ENV` environment variable. If the `LOG_ENV` environment variable contains + // errors, then this will return an error. + let env_filter = env_filter + .try_from_env() + .or_else(|_| env_filter.with_env_var(LOG_ENV.clone()).from_env())?; + let file_subscriber = fmt::layer() + .with_file(true) + .with_line_number(true) + .with_writer(log_file) + .with_target(false) + .with_ansi(false) + .with_filter(env_filter); + tracing_subscriber::registry() + .with(file_subscriber) + .with(ErrorLayer::default()) + .try_init()?; + Ok(()) +} diff --git a/src/main.rs b/src/main.rs index e7a11a9..b87ff47 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,14 @@ -fn main() { - println!("Hello, world!"); +use clap::Parser; +use mojibake::{app::App, cli::Cli, errors, logging}; + +#[tokio::main] +async fn main() -> color_eyre::Result<()> { + errors::init()?; + logging::init()?; + + let args = Cli::parse(); + // MJB-LLR-111: the optional positional path is handed to the buffer. + let mut app = App::new(args.tick_rate, args.frame_rate, args.file)?; + app.run().await?; + Ok(()) } diff --git a/src/tui.rs b/src/tui.rs new file mode 100644 index 0000000..8188985 --- /dev/null +++ b/src/tui.rs @@ -0,0 +1,233 @@ + +use std::{ + io::{Stdout, stdout}, + ops::{Deref, DerefMut}, + time::Duration, +}; + +use crossterm::{ + cursor, + event::{ + DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture, + Event as CrosstermEvent, EventStream, KeyEvent, KeyEventKind, MouseEvent, + }, + terminal::{EnterAlternateScreen, LeaveAlternateScreen}, +}; +use futures::{FutureExt, StreamExt}; +use ratatui::backend::CrosstermBackend as Backend; +use serde::{Deserialize, Serialize}; +use tokio::{ + sync::mpsc::{self, UnboundedReceiver, UnboundedSender}, + task::JoinHandle, + time::interval, +}; +use tokio_util::sync::CancellationToken; +use tracing::error; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub enum Event { + Init, + Quit, + Error, + Closed, + Tick, + Render, + FocusGained, + FocusLost, + Paste(String), + Key(KeyEvent), + Mouse(MouseEvent), + Resize(u16, u16), +} + +pub struct Tui { + pub terminal: ratatui::Terminal<Backend<Stdout>>, + pub task: JoinHandle<()>, + pub cancellation_token: CancellationToken, + pub event_rx: UnboundedReceiver<Event>, + pub event_tx: UnboundedSender<Event>, + pub frame_rate: f64, + pub tick_rate: f64, + pub mouse: bool, + pub paste: bool, +} + +impl Tui { + pub fn new() -> color_eyre::Result<Self> { + let (event_tx, event_rx) = mpsc::unbounded_channel(); + Ok(Self { + terminal: ratatui::Terminal::new(Backend::new(stdout()))?, + task: tokio::spawn(async {}), + cancellation_token: CancellationToken::new(), + event_rx, + event_tx, + frame_rate: 60.0, + tick_rate: 4.0, + mouse: false, + paste: false, + }) + } + + pub fn tick_rate(mut self, tick_rate: f64) -> Self { + self.tick_rate = tick_rate; + self + } + + pub fn frame_rate(mut self, frame_rate: f64) -> Self { + self.frame_rate = frame_rate; + self + } + + pub fn mouse(mut self, mouse: bool) -> Self { + self.mouse = mouse; + self + } + + pub fn paste(mut self, paste: bool) -> Self { + self.paste = paste; + self + } + + pub fn start(&mut self) { + self.cancel(); // Cancel any existing task + self.cancellation_token = CancellationToken::new(); + let event_loop = Self::event_loop( + self.event_tx.clone(), + self.cancellation_token.clone(), + self.tick_rate, + self.frame_rate, + ); + self.task = tokio::spawn(async { + event_loop.await; + }); + } + + async fn event_loop( + event_tx: UnboundedSender<Event>, + cancellation_token: CancellationToken, + tick_rate: f64, + frame_rate: f64, + ) { + let mut event_stream = EventStream::new(); + let mut tick_interval = interval(Duration::from_secs_f64(1.0 / tick_rate)); + let mut render_interval = interval(Duration::from_secs_f64(1.0 / frame_rate)); + + // if this fails, then it's likely a bug in the calling code + event_tx + .send(Event::Init) + .expect("failed to send init event"); + loop { + let event = tokio::select! { + _ = cancellation_token.cancelled() => { + break; + } + _ = tick_interval.tick() => Event::Tick, + _ = render_interval.tick() => Event::Render, + crossterm_event = event_stream.next().fuse() => match crossterm_event { + Some(Ok(event)) => match event { + CrosstermEvent::Key(key) if key.kind == KeyEventKind::Press => Event::Key(key), + CrosstermEvent::Mouse(mouse) => Event::Mouse(mouse), + CrosstermEvent::Resize(x, y) => Event::Resize(x, y), + CrosstermEvent::FocusLost => Event::FocusLost, + CrosstermEvent::FocusGained => Event::FocusGained, + CrosstermEvent::Paste(s) => Event::Paste(s), + _ => continue, // ignore other events + } + Some(Err(_)) => Event::Error, + None => break, // the event stream has stopped and will not produce any more events + }, + }; + if event_tx.send(event).is_err() { + // the receiver has been dropped, so there's no point in continuing the loop + break; + } + } + cancellation_token.cancel(); + } + + pub fn stop(&self) -> color_eyre::Result<()> { + self.cancel(); + let mut counter = 0; + while !self.task.is_finished() { + std::thread::sleep(Duration::from_millis(1)); + counter += 1; + if counter > 50 { + self.task.abort(); + } + if counter > 100 { + error!("Failed to abort task in 100 milliseconds for unknown reason"); + break; + } + } + Ok(()) + } + + pub fn enter(&mut self) -> color_eyre::Result<()> { + crossterm::terminal::enable_raw_mode()?; + crossterm::execute!(stdout(), EnterAlternateScreen, cursor::Hide)?; + if self.mouse { + crossterm::execute!(stdout(), EnableMouseCapture)?; + } + if self.paste { + crossterm::execute!(stdout(), EnableBracketedPaste)?; + } + self.start(); + Ok(()) + } + + pub fn exit(&mut self) -> color_eyre::Result<()> { + self.stop()?; + if crossterm::terminal::is_raw_mode_enabled()? { + self.flush()?; + if self.paste { + crossterm::execute!(stdout(), DisableBracketedPaste)?; + } + if self.mouse { + crossterm::execute!(stdout(), DisableMouseCapture)?; + } + crossterm::execute!(stdout(), LeaveAlternateScreen, cursor::Show)?; + crossterm::terminal::disable_raw_mode()?; + } + Ok(()) + } + + pub fn cancel(&self) { + self.cancellation_token.cancel(); + } + + pub fn suspend(&mut self) -> color_eyre::Result<()> { + self.exit()?; + #[cfg(not(windows))] + signal_hook::low_level::raise(signal_hook::consts::signal::SIGTSTP)?; + Ok(()) + } + + pub fn resume(&mut self) -> color_eyre::Result<()> { + self.enter()?; + Ok(()) + } + + pub async fn next_event(&mut self) -> Option<Event> { + self.event_rx.recv().await + } +} + +impl Deref for Tui { + type Target = ratatui::Terminal<Backend<Stdout>>; + + fn deref(&self) -> &Self::Target { + &self.terminal + } +} + +impl DerefMut for Tui { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.terminal + } +} + +impl Drop for Tui { + fn drop(&mut self) { + self.exit().unwrap(); + } +} |
