From ea68bcdc401a99d693f20b81dded2fa4891d9972 Mon Sep 17 00:00:00 2001 From: takeokunn Date: Sat, 8 Aug 2026 19:26:02 +0900 Subject: [PATCH 1/2] feat(editor): add paredit editor, a terminal-native text editing session paredit-feature-editor owns the text-buffer aggregate, cursor invariants, editing actions, and undo/redo session state behind a DocumentStore port; the presentation layer adds a thin raw-mode terminal adapter (paredit editor ) that translates keypresses into the feature's application API and renders the returned state. Also fixes two defects found while landing this branch: a non- exhaustive Key::Ctrl match that made the workspace fail to compile, and a dead Key::Quit variant that left Ctrl-C/Ctrl-Q/Escape wired to nothing, so the editor had no interactive way to quit. --- Cargo.lock | 5 + Cargo.toml | 1 + packages/feature/editor/Cargo.toml | 13 + packages/feature/editor/README.md | 9 + packages/feature/editor/src/editor/domain.rs | 258 ++++++++++++++ .../editor/src/editor/infrastructure.rs | 25 ++ packages/feature/editor/src/editor/mod.rs | 6 + packages/feature/editor/src/editor/usecase.rs | 333 ++++++++++++++++++ packages/feature/editor/src/lib.rs | 7 + src/presentation/cli.rs | 8 +- src/presentation/cli/command.rs | 2 + src/presentation/cli/dispatch.rs | 6 +- src/presentation/editor/mod.rs | 105 ++++++ src/presentation/editor/raw_mode.rs | 163 +++++++++ src/presentation/editor/render.rs | 82 +++++ src/presentation/mod.rs | 1 + tests/cli/capabilities_contract.rs | 1 + 17 files changed, 1020 insertions(+), 5 deletions(-) create mode 100644 packages/feature/editor/Cargo.toml create mode 100644 packages/feature/editor/README.md create mode 100644 packages/feature/editor/src/editor/domain.rs create mode 100644 packages/feature/editor/src/editor/infrastructure.rs create mode 100644 packages/feature/editor/src/editor/mod.rs create mode 100644 packages/feature/editor/src/editor/usecase.rs create mode 100644 packages/feature/editor/src/lib.rs create mode 100644 src/presentation/editor/mod.rs create mode 100644 src/presentation/editor/raw_mode.rs create mode 100644 src/presentation/editor/render.rs diff --git a/Cargo.lock b/Cargo.lock index 9e561bc65..3cb8ac8eb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -666,6 +666,7 @@ dependencies = [ "paredit-feature-code-metrics", "paredit-feature-conditional-conversion", "paredit-feature-data-report", + "paredit-feature-editor", "paredit-feature-emacs-lisp", "paredit-feature-external-check", "paredit-feature-extract", @@ -902,6 +903,10 @@ dependencies = [ "serde_json", ] +[[package]] +name = "paredit-feature-editor" +version = "1.5.0" + [[package]] name = "paredit-feature-emacs-lisp" version = "1.5.0" diff --git a/Cargo.toml b/Cargo.toml index 685f73471..dc619f1f1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -102,6 +102,7 @@ paredit-core-safety = { path = "packages/core/safety" } paredit-feature-binding = { path = "packages/feature/binding" } paredit-feature-change-summary = { path = "packages/feature/change-summary" } +paredit-feature-editor = { path = "packages/feature/editor" } paredit-feature-function-parameter = { path = "packages/feature/function-parameter" } paredit-feature-package = { path = "packages/feature/package" } paredit-feature-remove-unused = { path = "packages/feature/remove-unused" } diff --git a/packages/feature/editor/Cargo.toml b/packages/feature/editor/Cargo.toml new file mode 100644 index 000000000..110ab36ca --- /dev/null +++ b/packages/feature/editor/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "paredit-feature-editor" +description = "Domain and application services for the interactive text editor" +readme = "README.md" +publish = false +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[lints] +workspace = true diff --git a/packages/feature/editor/README.md b/packages/feature/editor/README.md new file mode 100644 index 000000000..825c13411 --- /dev/null +++ b/packages/feature/editor/README.md @@ -0,0 +1,9 @@ +# paredit-feature-editor + +The editor feature owns the text-buffer aggregate, cursor invariants, editing +actions, undo/redo session state, and the document storage port used by the +interactive command. + +The terminal adapter stays in the root application because it is a delivery +mechanism. It depends on this crate through the public `EditorSession` API and +does not reach into the feature's internal state. diff --git a/packages/feature/editor/src/editor/domain.rs b/packages/feature/editor/src/editor/domain.rs new file mode 100644 index 000000000..05b89a4fd --- /dev/null +++ b/packages/feature/editor/src/editor/domain.rs @@ -0,0 +1,258 @@ +//! Editor invariants that do not depend on a terminal or filesystem. + +/// A cursor position measured in Unicode scalar values, not bytes. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct Cursor { + line: usize, + column: usize, +} + +impl Cursor { + /// Creates a cursor at a line and character column. + #[must_use] + pub const fn new(line: usize, column: usize) -> Self { + Self { line, column } + } + + /// Returns the zero-based line. + #[must_use] + pub const fn line(self) -> usize { + self.line + } + + /// Returns the zero-based Unicode scalar-value column. + #[must_use] + pub const fn column(self) -> usize { + self.column + } +} + +/// A non-empty sequence of UTF-8 lines with cursor-safe editing operations. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct TextBuffer { + lines: Vec, +} + +impl TextBuffer { + /// Builds a buffer while preserving trailing newlines. + #[must_use] + pub fn from_text(text: &str) -> Self { + let mut lines: Vec = text.split('\n').map(ToOwned::to_owned).collect(); + if lines.is_empty() { + lines.push(String::new()); + } + Self { lines } + } + + /// Serializes the buffer back to UTF-8 text. + #[must_use] + pub fn to_text(&self) -> String { + self.lines.join("\n") + } + + /// Returns all lines in source order. + #[must_use] + pub fn lines(&self) -> &[String] { + &self.lines + } + + /// Returns one line, if it exists. + #[must_use] + pub fn line(&self, line: usize) -> Option<&str> { + self.lines.get(line).map(String::as_str) + } + + /// Returns the number of lines, always at least one. + #[must_use] + pub fn line_count(&self) -> usize { + self.lines.len() + } + + /// Inserts one character at the cursor and advances the cursor. + pub fn insert_char(&mut self, cursor: &mut Cursor, character: char) -> bool { + if character == '\n' { + return self.newline(cursor); + } + + self.normalize_cursor(cursor); + let byte_index = self.byte_index(*cursor); + self.lines[cursor.line].insert(byte_index, character); + cursor.column += 1; + true + } + + /// Inserts a string one Unicode scalar value at a time. + pub fn insert_str(&mut self, cursor: &mut Cursor, text: &str) -> bool { + let mut changed = false; + for character in text.chars() { + changed |= self.insert_char(cursor, character); + } + changed + } + + /// Splits the current line at the cursor and moves to the new line. + pub fn newline(&mut self, cursor: &mut Cursor) -> bool { + self.normalize_cursor(cursor); + let byte_index = self.byte_index(*cursor); + let suffix = self.lines[cursor.line].split_off(byte_index); + self.lines.insert(cursor.line + 1, suffix); + cursor.line += 1; + cursor.column = 0; + true + } + + /// Deletes the character before the cursor, joining lines when needed. + pub fn backspace(&mut self, cursor: &mut Cursor) -> bool { + self.normalize_cursor(cursor); + if cursor.column > 0 { + let byte_index = self.byte_index(Cursor::new(cursor.line, cursor.column - 1)); + self.lines[cursor.line].remove(byte_index); + cursor.column -= 1; + return true; + } + + if cursor.line == 0 { + return false; + } + + let current_line = self.lines.remove(cursor.line); + cursor.line -= 1; + cursor.column = self.lines[cursor.line].chars().count(); + self.lines[cursor.line].push_str(¤t_line); + true + } + + /// Deletes the character under the cursor, joining lines when needed. + pub fn delete(&mut self, cursor: &mut Cursor) -> bool { + self.normalize_cursor(cursor); + let line_length = self.lines[cursor.line].chars().count(); + if cursor.column < line_length { + let byte_index = self.byte_index(*cursor); + self.lines[cursor.line].remove(byte_index); + return true; + } + + if cursor.line + 1 == self.lines.len() { + return false; + } + + let next_line = self.lines.remove(cursor.line + 1); + self.lines[cursor.line].push_str(&next_line); + true + } + + /// Moves one character left, crossing the previous line when needed. + pub fn move_left(&self, cursor: &mut Cursor) { + self.normalize_cursor(cursor); + if cursor.column > 0 { + cursor.column -= 1; + } else if cursor.line > 0 { + cursor.line -= 1; + cursor.column = self.lines[cursor.line].chars().count(); + } + } + + /// Moves one character right, crossing the next line when needed. + pub fn move_right(&self, cursor: &mut Cursor) { + self.normalize_cursor(cursor); + let line_length = self.lines[cursor.line].chars().count(); + if cursor.column < line_length { + cursor.column += 1; + } else if cursor.line + 1 < self.lines.len() { + cursor.line += 1; + cursor.column = 0; + } + } + + /// Moves up while keeping the requested horizontal column when possible. + pub fn move_up(&self, cursor: &mut Cursor) { + self.normalize_cursor(cursor); + if cursor.line > 0 { + cursor.line -= 1; + cursor.column = cursor.column.min(self.lines[cursor.line].chars().count()); + } + } + + /// Moves down while keeping the requested horizontal column when possible. + pub fn move_down(&self, cursor: &mut Cursor) { + self.normalize_cursor(cursor); + if cursor.line + 1 < self.lines.len() { + cursor.line += 1; + cursor.column = cursor.column.min(self.lines[cursor.line].chars().count()); + } + } + + /// Moves to the beginning of the current line. + pub fn move_home(&self, cursor: &mut Cursor) { + self.normalize_cursor(cursor); + cursor.column = 0; + } + + /// Moves to the end of the current line. + pub fn move_end(&self, cursor: &mut Cursor) { + self.normalize_cursor(cursor); + cursor.column = self.lines[cursor.line].chars().count(); + } + + fn normalize_cursor(&self, cursor: &mut Cursor) { + cursor.line = cursor.line.min(self.lines.len().saturating_sub(1)); + cursor.column = cursor.column.min(self.lines[cursor.line].chars().count()); + } + + fn byte_index(&self, cursor: Cursor) -> usize { + self.lines[cursor.line] + .char_indices() + .nth(cursor.column) + .map_or_else(|| self.lines[cursor.line].len(), |(index, _)| index) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn preserves_trailing_newline() { + let buffer = TextBuffer::from_text("(a)\n"); + + assert_eq!(buffer.lines(), &["(a)".to_owned(), String::new()]); + assert_eq!(buffer.to_text(), "(a)\n"); + } + + #[test] + fn edits_unicode_by_character_column() { + let mut buffer = TextBuffer::from_text("λx"); + let mut cursor = Cursor::new(0, 1); + + buffer.insert_char(&mut cursor, '🙂'); + buffer.backspace(&mut cursor); + + assert_eq!(buffer.to_text(), "λx"); + assert_eq!(cursor, Cursor::new(0, 1)); + } + + #[test] + fn joins_and_splits_lines_at_cursor() { + let mut buffer = TextBuffer::from_text("ab\ncd"); + let mut cursor = Cursor::new(1, 1); + + buffer.newline(&mut cursor); + assert_eq!(buffer.to_text(), "ab\nc\nd"); + buffer.backspace(&mut cursor); + assert_eq!(buffer.to_text(), "ab\ncd"); + assert_eq!(cursor, Cursor::new(1, 1)); + } + + #[test] + fn movement_crosses_lines_and_clamps_columns() { + let buffer = TextBuffer::from_text("long\nx"); + let mut cursor = Cursor::new(0, 4); + + buffer.move_right(&mut cursor); + assert_eq!(cursor, Cursor::new(1, 0)); + buffer.move_left(&mut cursor); + assert_eq!(cursor, Cursor::new(0, 4)); + buffer.move_down(&mut cursor); + assert_eq!(cursor, Cursor::new(1, 1)); + } +} diff --git a/packages/feature/editor/src/editor/infrastructure.rs b/packages/feature/editor/src/editor/infrastructure.rs new file mode 100644 index 000000000..2908f50fd --- /dev/null +++ b/packages/feature/editor/src/editor/infrastructure.rs @@ -0,0 +1,25 @@ +//! Filesystem adapters for the editor application service. + +use std::fs; +use std::io; +use std::path::Path; + +use super::usecase::DocumentStore; + +/// The production document store used by the terminal editor. +#[derive(Debug, Clone, Copy, Default)] +pub struct FileDocumentStore; + +impl DocumentStore for FileDocumentStore { + fn load(&self, path: &Path) -> io::Result { + match fs::read_to_string(path) { + Ok(text) => Ok(text), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(String::new()), + Err(error) => Err(error), + } + } + + fn save(&self, path: &Path, text: &str) -> io::Result<()> { + fs::write(path, text) + } +} diff --git a/packages/feature/editor/src/editor/mod.rs b/packages/feature/editor/src/editor/mod.rs new file mode 100644 index 000000000..75d6b7088 --- /dev/null +++ b/packages/feature/editor/src/editor/mod.rs @@ -0,0 +1,6 @@ +//! The editor feature slice: domain state, application use cases, and I/O +//! adapters. + +pub mod domain; +pub mod infrastructure; +pub mod usecase; diff --git a/packages/feature/editor/src/editor/usecase.rs b/packages/feature/editor/src/editor/usecase.rs new file mode 100644 index 000000000..4c79dd3ce --- /dev/null +++ b/packages/feature/editor/src/editor/usecase.rs @@ -0,0 +1,333 @@ +//! Application services for an editor session. + +use std::fmt::{Display, Formatter}; +use std::io; +use std::path::{Path, PathBuf}; + +use super::domain::{Cursor, TextBuffer}; + +/// Persistence boundary used by the editor application service. +pub trait DocumentStore { + /// Reads a document from its path. + fn load(&self, path: &Path) -> io::Result; + + /// Writes a document to its path. + fn save(&self, path: &Path, text: &str) -> io::Result<()>; +} + +/// The actions a delivery adapter can send to an editor session. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EditorAction { + Insert(char), + Newline, + Backspace, + Delete, + Left, + Right, + Up, + Down, + Home, + End, + Save, + Undo, + Redo, +} + +/// The result of applying one action. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct ActionResult { + /// Whether the buffer or cursor history changed. + pub changed: bool, + /// Whether the action completed a save. + pub saved: bool, +} + +/// Errors crossing the storage boundary. +#[derive(Debug)] +pub enum EditorError { + Load { path: PathBuf, source: io::Error }, + Save { path: PathBuf, source: io::Error }, +} + +impl Display for EditorError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::Load { path, source } => { + write!(formatter, "could not read {}: {source}", path.display()) + } + Self::Save { path, source } => { + write!(formatter, "could not write {}: {source}", path.display()) + } + } + } +} + +impl std::error::Error for EditorError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Load { source, .. } | Self::Save { source, .. } => Some(source), + } + } +} + +#[derive(Debug, Clone)] +struct Snapshot { + buffer: TextBuffer, + cursor: Cursor, +} + +/// The aggregate representing one open document and its editing history. +#[derive(Debug)] +pub struct EditorSession { + path: PathBuf, + buffer: TextBuffer, + cursor: Cursor, + saved_text: String, + undo: Vec, + redo: Vec, +} + +impl EditorSession { + /// Opens a document through the supplied persistence port. + pub fn open(path: &Path, store: &S) -> Result { + let text = store.load(path).map_err(|source| EditorError::Load { + path: path.to_owned(), + source, + })?; + Ok(Self::new(path.to_owned(), text)) + } + + /// Creates a session from already-loaded text. + #[must_use] + pub fn new(path: PathBuf, text: String) -> Self { + let buffer = TextBuffer::from_text(&text); + Self { + path, + buffer, + cursor: Cursor::default(), + saved_text: text, + undo: Vec::new(), + redo: Vec::new(), + } + } + + /// Applies an action and returns its observable outcome. + pub fn apply( + &mut self, + action: EditorAction, + store: &S, + ) -> Result { + match action { + EditorAction::Insert(character) => { + Ok(self.edit(|buffer, cursor| buffer.insert_char(cursor, character))) + } + EditorAction::Newline => Ok(self.edit(TextBuffer::newline)), + EditorAction::Backspace => Ok(self.edit(TextBuffer::backspace)), + EditorAction::Delete => Ok(self.edit(TextBuffer::delete)), + EditorAction::Left => { + self.buffer.move_left(&mut self.cursor); + Ok(ActionResult::default()) + } + EditorAction::Right => { + self.buffer.move_right(&mut self.cursor); + Ok(ActionResult::default()) + } + EditorAction::Up => { + self.buffer.move_up(&mut self.cursor); + Ok(ActionResult::default()) + } + EditorAction::Down => { + self.buffer.move_down(&mut self.cursor); + Ok(ActionResult::default()) + } + EditorAction::Home => { + self.buffer.move_home(&mut self.cursor); + Ok(ActionResult::default()) + } + EditorAction::End => { + self.buffer.move_end(&mut self.cursor); + Ok(ActionResult::default()) + } + EditorAction::Save => { + self.save(store)?; + Ok(ActionResult { + saved: true, + ..ActionResult::default() + }) + } + EditorAction::Undo => Ok(self.restore_undo()), + EditorAction::Redo => Ok(self.restore_redo()), + } + } + + /// Saves the current text and clears the dirty state. + pub fn save(&mut self, store: &S) -> Result<(), EditorError> { + let text = self.buffer.to_text(); + store + .save(&self.path, &text) + .map_err(|source| EditorError::Save { + path: self.path.clone(), + source, + })?; + self.saved_text = text; + Ok(()) + } + + /// Returns the open document's path. + #[must_use] + pub fn path(&self) -> &Path { + &self.path + } + + /// Returns the current buffer. + #[must_use] + pub const fn buffer(&self) -> &TextBuffer { + &self.buffer + } + + /// Returns the current cursor. + #[must_use] + pub const fn cursor(&self) -> Cursor { + self.cursor + } + + /// Returns whether the current text differs from the last successful save. + #[must_use] + pub fn is_dirty(&self) -> bool { + self.buffer.to_text() != self.saved_text + } + + fn edit(&mut self, operation: F) -> ActionResult + where + F: FnOnce(&mut TextBuffer, &mut Cursor) -> bool, + { + let snapshot = Snapshot { + buffer: self.buffer.clone(), + cursor: self.cursor, + }; + if !operation(&mut self.buffer, &mut self.cursor) { + return ActionResult::default(); + } + self.undo.push(snapshot); + self.redo.clear(); + ActionResult { + changed: true, + ..ActionResult::default() + } + } + + fn restore_undo(&mut self) -> ActionResult { + let Some(snapshot) = self.undo.pop() else { + return ActionResult::default(); + }; + self.redo.push(Snapshot { + buffer: self.buffer.clone(), + cursor: self.cursor, + }); + self.buffer = snapshot.buffer; + self.cursor = snapshot.cursor; + ActionResult { + changed: true, + ..ActionResult::default() + } + } + + fn restore_redo(&mut self) -> ActionResult { + let Some(snapshot) = self.redo.pop() else { + return ActionResult::default(); + }; + self.undo.push(Snapshot { + buffer: self.buffer.clone(), + cursor: self.cursor, + }); + self.buffer = snapshot.buffer; + self.cursor = snapshot.cursor; + ActionResult { + changed: true, + ..ActionResult::default() + } + } +} + +#[cfg(test)] +mod tests { + use std::cell::RefCell; + use std::collections::HashMap; + + use super::*; + + #[derive(Default)] + struct MemoryStore { + files: RefCell>, + } + + impl DocumentStore for MemoryStore { + fn load(&self, path: &Path) -> io::Result { + Ok(self.files.borrow().get(path).cloned().unwrap_or_default()) + } + + fn save(&self, path: &Path, text: &str) -> io::Result<()> { + self.files + .borrow_mut() + .insert(path.to_owned(), text.to_owned()); + Ok(()) + } + } + + #[test] + fn edit_undo_redo_and_save_follow_the_document_lifecycle() { + let store = MemoryStore::default(); + let path = PathBuf::from("fixture.lisp"); + store + .files + .borrow_mut() + .insert(path.clone(), "(a)".to_owned()); + let mut session = EditorSession::open(&path, &store).expect("document opens"); + + session + .apply(EditorAction::End, &store) + .expect("move succeeds"); + session + .apply(EditorAction::Insert('!'), &store) + .expect("insert succeeds"); + assert!(session.is_dirty()); + session + .apply(EditorAction::Undo, &store) + .expect("undo succeeds"); + assert_eq!(session.buffer().to_text(), "(a)"); + session + .apply(EditorAction::Redo, &store) + .expect("redo succeeds"); + assert_eq!(session.buffer().to_text(), "(a)!"); + session + .apply(EditorAction::Save, &store) + .expect("save succeeds"); + assert!(!session.is_dirty()); + assert_eq!(store.files.borrow().get(&path), Some(&"(a)!".to_owned())); + } + + #[test] + fn a_new_edit_invalidates_redo_history() { + let store = MemoryStore::default(); + let mut session = EditorSession::new(PathBuf::from("fixture"), "a".to_owned()); + + session + .apply(EditorAction::End, &store) + .expect("move succeeds"); + session + .apply(EditorAction::Insert('b'), &store) + .expect("insert succeeds"); + session + .apply(EditorAction::Undo, &store) + .expect("undo succeeds"); + session + .apply(EditorAction::Insert('c'), &store) + .expect("insert succeeds"); + let result = session + .apply(EditorAction::Redo, &store) + .expect("redo is a no-op"); + + assert!(!result.changed); + assert_eq!(session.buffer().to_text(), "ac"); + } +} diff --git a/packages/feature/editor/src/lib.rs b/packages/feature/editor/src/lib.rs new file mode 100644 index 000000000..6c3a5d5cc --- /dev/null +++ b/packages/feature/editor/src/lib.rs @@ -0,0 +1,7 @@ +#![doc = include_str!("../README.md")] + +pub mod editor; + +pub use editor::domain::{Cursor, TextBuffer}; +pub use editor::infrastructure::FileDocumentStore; +pub use editor::usecase::{ActionResult, DocumentStore, EditorAction, EditorError, EditorSession}; diff --git a/src/presentation/cli.rs b/src/presentation/cli.rs index c66df9701..b76956db3 100644 --- a/src/presentation/cli.rs +++ b/src/presentation/cli.rs @@ -221,15 +221,15 @@ pub fn run() -> ExitCode { let plain_language = cli.plain_language; let explain_error = cli.explain_error; - // The protocol servers own their own exit status, and are therefore taken - // before dispatch. A session normally ends with the client closing the - // pipe, and routing that through the `Result` path below would report every - // clean shutdown as an error. + // The protocol servers and interactive sessions own their own exit status, + // and are therefore taken before dispatch. A session normally ends with + // the client closing the pipe or the editor restoring the terminal. let command = match cli.command { Command::Lsp(args) => return crate::presentation::lsp::lsp(args), Command::Mcp(args) => return crate::presentation::mcp::mcp(args), Command::Serve(args) => return crate::presentation::serve::serve(args), Command::Tui(args) => return crate::presentation::tui::tui(args), + Command::Editor(args) => return crate::presentation::editor::editor(args), command => command, }; diff --git a/src/presentation/cli/command.rs b/src/presentation/cli/command.rs index 54563be60..d422c8f71 100644 --- a/src/presentation/cli/command.rs +++ b/src/presentation/cli/command.rs @@ -1230,6 +1230,8 @@ pub(super) enum Command { Serve(crate::presentation::serve::ServeArgs), /// Interactively browse one file's tree; prints the last selected --path on exit. Tui(crate::presentation::tui::TuiArgs), + /// Interactively edit one file in a terminal. + Editor(crate::presentation::editor::EditorArgs), /// Print a shell completion script to stdout. Completions { /// Shell to generate a completion script for. diff --git a/src/presentation/cli/dispatch.rs b/src/presentation/cli/dispatch.rs index 82c9081f9..4ae0da5f6 100644 --- a/src/presentation/cli/dispatch.rs +++ b/src/presentation/cli/dispatch.rs @@ -1363,7 +1363,11 @@ pub(super) fn dispatch(command: Command) -> CommandResult { // Handled in `run`, before dispatch, because they own their exit // status: a closed pipe is how a protocol session normally ends, and // `tui`'s own `q` quit is not this process failing either. - Command::Lsp(_) | Command::Mcp(_) | Command::Serve(_) | Command::Tui(_) => { + Command::Lsp(_) + | Command::Mcp(_) + | Command::Serve(_) + | Command::Tui(_) + | Command::Editor(_) => { unreachable!("the protocol servers and tui are dispatched from run") } Command::Completions { shell } => { diff --git a/src/presentation/editor/mod.rs b/src/presentation/editor/mod.rs new file mode 100644 index 000000000..f647c949f --- /dev/null +++ b/src/presentation/editor/mod.rs @@ -0,0 +1,105 @@ +//! `paredit editor` — a small, terminal-native editing session. + +//! The terminal adapter deliberately contains no buffer mutation logic. It +//! translates keypresses into the `paredit-feature-editor` application API, +//! while rendering the returned aggregate state. + +mod raw_mode; +mod render; + +use std::io::{IsTerminal, Write as _}; +use std::path::PathBuf; +use std::process::ExitCode; + +use clap::Args; +use paredit_feature_editor::{EditorAction, EditorSession, FileDocumentStore}; + +use raw_mode::Key; + +#[derive(Debug, Args)] +pub(crate) struct EditorArgs { + /// File to open, creating it on the first save when it does not exist. + file: PathBuf, +} + +pub(crate) fn editor(args: EditorArgs) -> ExitCode { + match run(&args) { + Ok(()) => ExitCode::SUCCESS, + Err(message) => { + eprintln!("Error: paredit editor: {message}"); + ExitCode::FAILURE + } + } +} + +fn run(args: &EditorArgs) -> Result<(), String> { + if !std::io::stdin().is_terminal() || !std::io::stdout().is_terminal() { + return Err("needs an interactive terminal on both stdin and stdout".to_owned()); + } + + let store = FileDocumentStore; + let mut session = EditorSession::open(&args.file, &store).map_err(|error| error.to_string())?; + let _raw_mode = raw_mode::enter().map_err(|error| error.to_string())?; + let _screen = ScreenGuard; + let mut stdout = std::io::stdout(); + + loop { + let frame = render::frame( + &session, + paredit_core_cli::terminal::width(), + terminal_height(), + ); + write!(stdout, "\x1b[?25l\x1b[2J\x1b[H{frame}").map_err(|error| error.to_string())?; + stdout.flush().map_err(|error| error.to_string())?; + + let Some(key) = raw_mode::read_key().map_err(|error| error.to_string())? else { + break; + }; + if let Some(action) = action_for(key) { + session + .apply(action, &store) + .map_err(|error| error.to_string())?; + } else if matches!(key, Key::Ctrl('q') | Key::Ctrl('c') | Key::Escape) { + break; + } + } + + Ok(()) +} + +const fn action_for(key: Key) -> Option { + match key { + Key::Char(character) => Some(EditorAction::Insert(character)), + Key::Enter => Some(EditorAction::Newline), + Key::Backspace => Some(EditorAction::Backspace), + Key::Delete => Some(EditorAction::Delete), + Key::Left => Some(EditorAction::Left), + Key::Right => Some(EditorAction::Right), + Key::Up => Some(EditorAction::Up), + Key::Down => Some(EditorAction::Down), + Key::Home => Some(EditorAction::Home), + Key::End => Some(EditorAction::End), + Key::Ctrl('s') => Some(EditorAction::Save), + Key::Ctrl('z') => Some(EditorAction::Undo), + Key::Ctrl('y') => Some(EditorAction::Redo), + Key::Ctrl(_) | Key::Escape | Key::Other => None, + } +} + +fn terminal_height() -> usize { + std::env::var("LINES") + .ok() + .and_then(|value| value.parse::().ok()) + .filter(|height| *height > 0) + .unwrap_or(24) +} + +struct ScreenGuard; + +impl Drop for ScreenGuard { + fn drop(&mut self) { + let mut stdout = std::io::stdout(); + let _ = write!(stdout, "\x1b[0m\x1b[?25h\r\n"); + let _ = stdout.flush(); + } +} diff --git a/src/presentation/editor/raw_mode.rs b/src/presentation/editor/raw_mode.rs new file mode 100644 index 000000000..1cbda41b4 --- /dev/null +++ b/src/presentation/editor/raw_mode.rs @@ -0,0 +1,163 @@ +//! POSIX raw mode and key decoding for the editor delivery adapter. + +#![allow(unsafe_code)] + +use std::io::Read as _; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum Key { + Char(char), + Enter, + Backspace, + Delete, + Left, + Right, + Up, + Down, + Home, + End, + Ctrl(char), + Escape, + Other, +} + +pub(super) fn read_key() -> std::io::Result> { + let mut byte = [0_u8; 1]; + if std::io::stdin().read(&mut byte)? == 0 { + return Ok(None); + } + + let key = match byte[0] { + 0x03 => Key::Ctrl('c'), + 0x08 | 0x7f => Key::Backspace, + 0x09 => Key::Char('\t'), + b'\r' | b'\n' => Key::Enter, + 0x11 => Key::Ctrl('q'), + 0x13 => Key::Ctrl('s'), + 0x19 => Key::Ctrl('y'), + 0x1a => Key::Ctrl('z'), + 0x1b => read_escape_sequence()?, + byte if byte.is_ascii_control() => Key::Other, + byte if byte.is_ascii() => Key::Char(byte as char), + first => read_utf8(first)?, + }; + Ok(Some(key)) +} + +fn read_utf8(first: u8) -> std::io::Result { + let width = match first { + 0xC2..=0xDF => 2, + 0xE0..=0xEF => 3, + 0xF0..=0xF4 => 4, + _ => return Ok(Key::Other), + }; + let mut bytes = vec![0_u8; width]; + bytes[0] = first; + std::io::stdin().read_exact(&mut bytes[1..])?; + Ok(std::str::from_utf8(&bytes) + .ok() + .and_then(|text| text.chars().next()) + .map_or(Key::Other, Key::Char)) +} + +fn read_escape_sequence() -> std::io::Result { + let Some(first) = read_byte()? else { + return Ok(Key::Escape); + }; + if first != b'[' { + return Ok(Key::Escape); + } + + let Some(second) = read_byte()? else { + return Ok(Key::Escape); + }; + Ok(match second { + b'A' => Key::Up, + b'B' => Key::Down, + b'C' => Key::Right, + b'D' => Key::Left, + b'H' => Key::Home, + b'F' => Key::End, + b'1' => { + let _ = read_byte()?; + Key::Home + } + b'3' => { + let _ = read_byte()?; + Key::Delete + } + b'4' => { + let _ = read_byte()?; + Key::End + } + _ => Key::Other, + }) +} + +fn read_byte() -> std::io::Result> { + let mut byte = [0_u8; 1]; + if std::io::stdin().read(&mut byte)? == 0 { + Ok(None) + } else { + Ok(Some(byte[0])) + } +} + +#[cfg(unix)] +mod unix { + use std::io::Error; + use std::os::fd::AsRawFd; + + #[derive(Debug)] + pub(in super::super) struct RawMode { + original: libc::termios, + } + + pub(in super::super) fn enter() -> std::io::Result { + let fd = std::io::stdin().as_raw_fd(); + // SAFETY: `fd` is stdin's own descriptor, and `tcgetattr` initializes + // the plain termios value before it is used. + let original = unsafe { + let mut original: libc::termios = std::mem::zeroed(); + if libc::tcgetattr(fd, &raw mut original) != 0 { + return Err(Error::last_os_error()); + } + original + }; + + let mut raw = original; + // SAFETY: `raw` was initialized by `tcgetattr` and is passed back to + // the same terminal descriptor. + unsafe { + libc::cfmakeraw(&raw mut raw); + if libc::tcsetattr(fd, libc::TCSANOW, &raw const raw) != 0 { + return Err(Error::last_os_error()); + } + } + Ok(RawMode { original }) + } + + impl Drop for RawMode { + fn drop(&mut self) { + let fd = std::io::stdin().as_raw_fd(); + // SAFETY: `original` came from `tcgetattr` for this descriptor. + unsafe { + libc::tcsetattr(fd, libc::TCSANOW, &raw const self.original); + } + } + } +} + +#[cfg(unix)] +pub(super) use unix::enter; + +#[cfg(not(unix))] +#[derive(Debug)] +pub(super) struct RawMode; + +#[cfg(not(unix))] +pub(super) fn enter() -> std::io::Result { + Err(std::io::Error::other( + "paredit editor needs a POSIX terminal, which this platform does not provide", + )) +} diff --git a/src/presentation/editor/render.rs b/src/presentation/editor/render.rs new file mode 100644 index 000000000..5ec9d1b3e --- /dev/null +++ b/src/presentation/editor/render.rs @@ -0,0 +1,82 @@ +//! Pure screen rendering for the editor adapter. + +use std::fmt::Write as _; + +use paredit_feature_editor::EditorSession; + +const GUTTER_WIDTH: usize = 7; + +pub(super) fn frame(session: &EditorSession, width: usize, height: usize) -> String { + let width = width.max(1); + let height = height.max(3); + let code_height = height - 1; + let cursor = session.cursor(); + let first_line = cursor.line().saturating_sub(code_height.saturating_sub(1)); + let mut output = String::new(); + + for row in 0..code_height { + let line_index = first_line + row; + let line = session.buffer().line(line_index).unwrap_or(""); + let source_budget = width.saturating_sub(GUTTER_WIDTH); + let source = clip(line, source_budget); + writeln!(output, "{:>4} │ {source}", line_index + 1).expect("String writes cannot fail"); + } + + let modified = if session.is_dirty() { " [+]" } else { "" }; + let status = format!( + " {}{} | line {}, col {} | C-s save C-z undo C-y redo C-q quit", + session.path().display(), + modified, + cursor.line() + 1, + cursor.column() + 1, + ); + writeln!(output, "{}", clip(&status, width)).expect("String writes cannot fail"); + + let screen_row = cursor.line().saturating_sub(first_line) + 1; + let screen_column = (GUTTER_WIDTH + cursor.column() + 1).min(width); + write!(output, "\x1b[{screen_row};{screen_column}H").expect("String writes cannot fail"); + output +} + +fn clip(text: &str, width: usize) -> String { + if text.chars().count() <= width { + return text.to_owned(); + } + text.chars().take(width).collect() +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use super::*; + + #[test] + fn frame_renders_line_numbers_dirty_state_and_cursor() { + let mut session = EditorSession::new(PathBuf::from("fixture.lisp"), "(a)\n(b)".to_owned()); + let store = paredit_feature_editor::FileDocumentStore; + session + .apply(paredit_feature_editor::EditorAction::End, &store) + .expect("move succeeds"); + session + .apply(paredit_feature_editor::EditorAction::Insert('!'), &store) + .expect("insert succeeds"); + + let rendered = frame(&session, 80, 8); + + assert!(rendered.contains(" 1 │ (a)!")); + assert!(rendered.contains(" 2 │ (b)")); + assert!(rendered.contains("fixture.lisp [+]")); + assert!(rendered.contains("\x1b[1;12H")); + } + + #[test] + fn frame_clips_source_to_terminal_width() { + let session = EditorSession::new(PathBuf::from("fixture"), "abcdefghijk".to_owned()); + + let rendered = frame(&session, 10, 4); + let source_line = rendered.lines().next().expect("source line exists"); + + assert!(source_line.chars().count() <= 10); + } +} diff --git a/src/presentation/mod.rs b/src/presentation/mod.rs index 273aef18a..05fc7f37d 100644 --- a/src/presentation/mod.rs +++ b/src/presentation/mod.rs @@ -2,6 +2,7 @@ //! the protocol servers — onto application services. pub mod cli; +pub(crate) mod editor; pub(crate) mod lsp; pub(crate) mod mcp; pub(crate) mod serve; diff --git a/tests/cli/capabilities_contract.rs b/tests/cli/capabilities_contract.rs index 6c2ae6514..d83518e36 100644 --- a/tests/cli/capabilities_contract.rs +++ b/tests/cli/capabilities_contract.rs @@ -86,6 +86,7 @@ fn capabilities_reports_the_canonical_namespaces_and_meta_commands() { "mcp", "serve", "tui", + "editor", "completions" ] ); From f5b3a14bdb9579c0f89e74f08c539384fba31f2f Mon Sep 17 00:00:00 2001 From: takeokunn Date: Sat, 8 Aug 2026 23:46:56 +0900 Subject: [PATCH 2/2] docs(readme): add editor to the Commands block readme_contract::readme_commands_match_top_level_help caught the drift: `paredit editor` exists in --help but was missing from the README table. --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index d713b3bf1..1807229cb 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ paredit lsp # Language Server Protocol server over stdio paredit mcp # Model Context Protocol server over stdio (agents) paredit serve # resident analysis server over HTTP/JSON-RPC paredit tui # interactively browse a file's tree; prints --path on exit +paredit editor # interactively edit one file in a terminal paredit completions # shell completion scripts (bash/zsh/fish/...) ```