From 01805bba0e118a47e6ad44121b6f130259161d3a Mon Sep 17 00:00:00 2001 From: Valdemar Lorenzen Date: Mon, 5 Oct 2026 14:30:40 +0200 Subject: [PATCH] refactor: move modal paste policy into editor input --- README.md | 10 +++-- src/editor.rs | 105 +++++++++++++++++++++++++++++++++++++++++++++++-- src/session.rs | 11 +----- src/tui.rs | 7 +--- 4 files changed, 110 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 7d8b15e..8f9c3c9 100644 --- a/README.md +++ b/README.md @@ -27,17 +27,19 @@ Commands: `:w [path]`, `:q`, `:q!`, `:wq [path]`, `:plugins`, and `:plugin-comma `:q` refuses unsaved changes. `:wq` quits only after a successful save. Saves use a same-directory temporary file and rename, preserve existing permissions, and reject target symlinks. UTF-8 text is edited by extended grapheme; tabs render at four-cell stops. LF and trailing-newline structure are preserved; CRLF and CR are normalized to LF. Binary/terminal-control-containing text is rejected. Bracketed paste inserts only in Insert mode and never executes pasted commands in Normal mode. +Paste is backend input: it is ignored in Normal and Command modes, and inserts literal text in Insert mode, with CRLF/CR normalization and a 4 MiB limit. Pasted text is one undo action, separate from typing before and after it. Rejected paste reports an error without changing text, cursor, mode, or pending insert history. + ## Module seams One crate, four modules, no workspace or frontend-specific types in the editing backend: -- `editor`: owns buffer, cursor, modes, undo/redo, saving, command ownership, capabilities, and validated edit effects. Its low-level interface returns an `Invocation` when an external command is selected. +- `editor`: owns buffer, cursor, modes, modal paste policy, undo/redo, saving, command ownership, capabilities, and validated edit effects. Its low-level interface returns an `Invocation` when an external command is selected. - `session`: `EditorSession` owns the editor and its Ruby hosts together. It loads configuration, registers eligible plugins before installing bindings, executes selected commands, and retires both host and command ownership on invocation or effect-validation failure. -- `tui`: translates terminal events to session input, renders read-only editor state, and advertises `text` and `styled_text` capabilities. It has no plugin lifecycle callback or host storage. +- `tui`: translates terminal events to session input, renders read-only editor state, and advertises `text` and `styled_text` capabilities. It has no plugin lifecycle callback, host storage, or paste acceptance policy. - `ruby`: evaluates configuration and runs isolated plugin hosts. Protocol, deadlines, and process cleanup stay inside this module. - The executable selects paths and frontend capabilities, opens an editor session, and launches the TUI. -Frontends drive `EditorSession::handle` or `execute` and inspect `editor()`; there is no mutable editor getter that could independently alter plugin registration. `apply` supports frontend-originated effects and reports validation errors; plugin effects are applied internally so a rejected result also retires its owner. The TUI retains its existing Insert-only paste policy and undo behavior. +Frontends drive `EditorSession::handle` or `execute` and inspect `editor()`; there is no mutable editor getter that could independently alter plugin registration. Pasted text uses `Input::Paste(String)` through the same input seam as keys. Plugin effects are applied internally so a rejected result also retires its owner; there is no generic frontend effects entry point. Built-in Ex commands register as the protected `core` feature in the same command registry used by plugins. External plugins cannot claim core status, replace existing commands, disable core, or bind over essential modal editing keys. `:plugins` lists active features and frontend capabilities. @@ -98,4 +100,4 @@ cargo fmt --check ruby -c src/ruby_host.rb ``` -Regression checks exercise Unicode edits, undo/redo, saving and failed saves, capability/command ownership, effect atomicity, real Ruby execution, protocol errors, crash/timeout recovery, and inherited-pipe cleanup. Session checks join real Ruby execution to capability/collision rejection, complete command/binding retirement after invalid effects or host failure, surviving plugins, core editing/saving, and broken-config fallback. The executable was also exercised in PTY sessions for editing/saving, rejected-effect retirement, plugin crash and hang recovery, bracketed paste, undo/redo, scrolling, resize, and terminal restoration. +Regression checks exercise Unicode edits, undo/redo, saving and failed saves, capability/command ownership, effect atomicity, real Ruby execution, protocol errors, crash/timeout recovery, and inherited-pipe cleanup. Backend paste checks cover ignored modes, literal Unicode/newline insertion, rejection atomicity, and typing/paste/typing undo and redo. Session checks join real Ruby execution to capability/collision rejection, complete command/binding retirement after invalid effects or host failure, surviving plugins, core editing/saving, and broken-config fallback. The executable was also exercised in PTY sessions for editing/saving, rejected-effect retirement, plugin crash and hang recovery, modal bracketed paste, rejected-paste feedback, undo/redo, scrolling, resize, and terminal restoration. diff --git a/src/editor.rs b/src/editor.rs index 3253eca..0265f61 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -10,13 +10,14 @@ use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; use unicode_segmentation::UnicodeSegmentation; -const MAX_EFFECT_BYTES: usize = 4 * 1024 * 1024; +const MAX_EDIT_BYTES: usize = 4 * 1024 * 1024; static TEMP_ID: AtomicU64 = AtomicU64::new(0); -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Clone, Debug, Eq, PartialEq)] pub enum Input { Char(char), Ctrl(char), + Paste(String), Escape, Enter, Backspace, @@ -323,6 +324,7 @@ impl Editor { let mut bytes = [0; 4]; self.insert_text(ch.encode_utf8(&mut bytes)); } + Input::Paste(text) => self.paste(&text), Input::Enter => self.insert_text("\n"), Input::Backspace => self.backspace(), Input::Delete => self.delete_forward(), @@ -429,7 +431,7 @@ impl Editor { total = total .checked_add(text.len()) .ok_or("Plugin effects exceed 4 MiB")?; - if total > MAX_EFFECT_BYTES { + if total > MAX_EDIT_BYTES { return Err("Plugin effects exceed 4 MiB".into()); } validate_text(text)?; @@ -510,6 +512,27 @@ impl Editor { } } + fn paste(&mut self, text: &str) { + let validation = if text.len() > MAX_EDIT_BYTES { + Err("Paste exceeds 4 MiB".into()) + } else { + validate_text(text) + }; + if let Err(error) = validation { + self.set_message(error); + return; + } + // Preserve the separate typing / paste / typing undo groups. + self.finish_insert(); + let before = (!text.is_empty()).then(|| self.snapshot()); + self.insert_text(&normalize_newlines(text)); + self.clamp_cursor(); + if let Some(before) = before { + self.record(before); + } + self.insert_start = Some(self.snapshot()); + } + fn undo(&mut self) { if let Some(before) = self.undo.pop() { let current = self.snapshot(); @@ -788,6 +811,80 @@ mod tests { } } + #[test] + fn paste_is_ignored_in_normal_and_command_modes_without_dispatching() { + let mut editor = editor(); + assert_eq!(editor.handle(Input::Paste(":q!\r\n".into())), None); + assert_eq!(editor.mode(), Mode::Normal); + assert_eq!(editor.context().text, ""); + assert!(!editor.dirty()); + assert!(!editor.should_quit()); + + editor.handle(Input::Char(':')); + type_text(&mut editor, "q"); + assert_eq!(editor.handle(Input::Paste("\rq!\n\0".into())), None); + assert_eq!(editor.mode(), Mode::Command); + assert_eq!(editor.command_line(), "q"); + assert_eq!(editor.context().text, ""); + assert!(!editor.should_quit()); + editor.handle(Input::Enter); + assert!(editor.should_quit()); + } + + #[test] + fn pasted_unicode_and_newlines_are_literal_and_a_separate_undo_action() { + let mut editor = editor(); + editor.handle(Input::Char('i')); + type_text(&mut editor, "a"); + assert_eq!( + editor.handle(Input::Paste("界e\u{301}\t\r\n:q!\r".into())), + None + ); + assert_eq!(editor.mode(), Mode::Insert); + assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 }); + type_text(&mut editor, "z"); + editor.handle(Input::Escape); + assert_eq!(editor.context().text, "a界e\u{301}\t\n:q!\nz"); + assert!(!editor.should_quit()); + + for expected in ["a界e\u{301}\t\n:q!\n", "a", ""] { + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, expected); + } + for expected in ["a", "a界e\u{301}\t\n:q!\n", "a界e\u{301}\t\n:q!\nz"] { + editor.handle(Input::Ctrl('r')); + assert_eq!(editor.context().text, expected); + } + } + + #[test] + fn rejected_paste_preserves_text_cursor_mode_and_pending_insert_history() { + for text in [ + "valid prefix\ninvalid\0".to_owned(), + "\u{1b}[31m".to_owned(), + "x".repeat(MAX_EDIT_BYTES + 1), + ] { + let mut editor = editor(); + editor.handle(Input::Char('i')); + type_text(&mut editor, "before"); + let cursor = editor.cursor(); + editor.handle(Input::Paste(text)); + assert_eq!(editor.context().text, "before"); + assert_eq!(editor.cursor(), cursor); + assert_eq!(editor.mode(), Mode::Insert); + assert!( + editor.message().contains("Unsupported control character") + || editor.message().contains("Paste exceeds 4 MiB") + ); + type_text(&mut editor, "after"); + editor.handle(Input::Escape); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, ""); + editor.handle(Input::Ctrl('r')); + assert_eq!(editor.context().text, "beforeafter"); + } + } + #[test] fn insert_sessions_and_open_lines_are_single_undo_actions() { let mut editor = editor(); @@ -996,7 +1093,7 @@ mod tests { assert!( editor .apply(vec![ - Effect::Insert("a".repeat(MAX_EFFECT_BYTES)), + Effect::Insert("a".repeat(MAX_EDIT_BYTES)), Effect::Insert("x".into()) ]) .is_err() diff --git a/src/session.rs b/src/session.rs index 0459998..db7e65e 100644 --- a/src/session.rs +++ b/src/session.rs @@ -5,7 +5,7 @@ use std::{ }; use crate::{ - editor::{Editor, Effect, Input, Invocation}, + editor::{Editor, Input, Invocation}, ruby::{Config, RubyPlugin, load_config}, }; @@ -77,15 +77,6 @@ impl EditorSession { } } - /// Applies frontend effects, such as the TUI's existing Insert-mode paste. - /// Plugin results are applied internally by dispatch, with owner retirement - /// on rejection; frontend effects have no plugin owner to retire. - pub fn apply(&mut self, effects: Vec) { - if let Err(error) = self.editor.apply(effects) { - self.editor.set_message(error); - } - } - fn dispatch(&mut self, invocation: Invocation) { let result = match self.plugins.get_mut(&invocation.plugin) { Some(plugin) => plugin diff --git a/src/tui.rs b/src/tui.rs index e35dc24..5ddd48d 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -21,7 +21,7 @@ use unicode_segmentation::UnicodeSegmentation; use unicode_width::UnicodeWidthStr; use crate::{ - editor::{Editor, Effect, Input, Mode}, + editor::{Editor, Input, Mode}, session::EditorSession, }; @@ -75,10 +75,7 @@ pub fn run(session: &mut EditorSession) -> io::Result<()> { session.handle(input); } } - // Bracketed paste must never execute pasted Ex commands. - Event::Paste(text) if session.editor().mode() == Mode::Insert => { - session.apply(vec![Effect::Insert(text)]); - } + Event::Paste(text) => session.handle(Input::Paste(text)), _ => {} } }