use crate::clipboard::{ClipboardMode, SystemClipboard}; use crate::files::{DockSide, FilePicker, FilePresentation, FileView}; use crate::plugin::{Diagnostic, TextPosition}; use serde::Serialize; use std::borrow::Cow; use std::collections::{BTreeMap, HashMap, HashSet}; use std::fmt; use std::fs::{self, OpenOptions}; use std::io::{self, Write}; #[cfg(unix)] use std::os::unix::fs::OpenOptionsExt; use std::path::{Component, Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; use unicode_segmentation::{GraphemeCursor, UnicodeSegmentation}; use unicode_width::UnicodeWidthStr; const MAX_EDIT_BYTES: usize = 4 * 1024 * 1024; static TEMP_ID: AtomicU64 = AtomicU64::new(0); #[derive(Clone, Debug, Eq, Hash, PartialEq)] pub enum Input { Char(char), Ctrl(char), Paste(String), Escape, Enter, Backspace, Delete, Left, Right, Up, Down, Home, End, } #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] pub enum Mode { Normal, Insert, Command, Search, Visual, } impl fmt::Display for Mode { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(match self { Self::Normal => "NORMAL", Self::Insert => "INSERT", Self::Command => "COMMAND", Self::Search => "SEARCH", Self::Visual => "VISUAL", }) } } pub struct Palette { pub query: String, /// Current filtered results, sorted by label then name. pub entries: Vec, pub selected: usize, } pub struct PaletteEntry { pub name: String, pub label: String, pub owner: String, /// Effective bindings in the underlying editor mode. pub bindings: Vec, pub enabled: bool, pub modes: Vec, } #[derive(Clone, Copy, Debug, Default, Eq, Ord, PartialEq, PartialOrd)] pub struct Cursor { pub row: usize, /// Zero-based extended grapheme index, not a byte or display-cell offset. pub column: usize, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct SearchMatch { pub start: Cursor, /// Exclusive end, in grapheme columns on the same row as `start`. pub end_column: usize, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct Selection { /// Inclusive start of the ordered range, in grapheme coordinates. pub start: Cursor, /// Exclusive end in grapheme coordinates; may be the next row's column 0. pub end: Cursor, } #[derive(Clone, Debug, Eq, PartialEq)] pub struct Feature { pub name: String, pub required_capabilities: Vec, pub commands: Vec, pub core: bool, } #[derive(Clone, Debug, Eq, PartialEq)] pub enum Effect { Insert(String), Replace(String), Message(String), } #[derive(Clone, Debug, Eq, PartialEq)] pub struct Invocation { pub plugin: String, pub command: String, pub args: String, } #[derive(Debug, Serialize)] pub struct Context { pub buffer_id: usize, pub revision: u64, pub root: String, pub text: String, /// One-based row, including the empty row after a trailing newline. pub row: usize, /// One-based extended grapheme column. pub column: usize, pub mode: String, pub path: Option, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] struct Target { anchor: Cursor, active: Cursor, // Display columns survive a clone onto a shorter line. preferred_columns: Option<(usize, usize)>, } impl Target { fn caret(cursor: Cursor) -> Self { Self { anchor: cursor, active: cursor, preferred_columns: None, } } } #[cfg_attr(test, derive(Debug))] struct Snapshot { lines: Vec, targets: Vec, primary: usize, } struct Edit<'a> { target: usize, start: usize, end: usize, text: Cow<'a, str>, caret: usize, round_up: bool, } // Recipes contain resolved native intent, never input keys or source-buffer offsets. #[cfg_attr(test, derive(Debug))] struct EditRecipe { start: EditStart, steps: Vec, } #[cfg_attr(test, derive(Debug))] enum EditStart { Insert(Action), Selection { change: bool, selector: SelectionIntent, }, Delete, Join, Paste { after: bool, fragments: Vec, }, } #[cfg_attr(test, derive(Debug))] enum SelectionIntent { Motions(Vec<(Action, usize)>), // Whole graphemes, with a newline counting as one boundary. Spans(Vec), } #[cfg_attr(test, derive(Debug))] enum InsertStep { Typed(String), Bulk(String), Move(Action, usize), Delete(Vec), Backspace(Vec), } #[cfg_attr(test, derive(Debug))] #[derive(Clone, Copy, Eq, PartialEq)] enum Action { Insert, InsertFirstNonblank, Append, AppendLineEnd, OpenBelow, OpenAbove, Left, Right, Down, Up, JoinLines, LineStart, LineEnd, Delete, Undo, Redo, RepeatLastEdit, Command, Normal, Newline, Backspace, Submit, Search, SearchNext, SearchPrevious, WordNext, WordPrevious, WordEnd, BigWordNext, BigWordPrevious, BigWordEnd, Select, Copy, Cut, CopySystem, Change, PasteBefore, PasteAfter, PasteSystemBefore, PasteSystemAfter, SelectLines, CloneAbove, CloneBelow, SelectNext, SkipOccurrence, SelectAll, Palette, FilePicker, FileExplorer, } // Names and mode eligibility are shared by configured bindings and the palette. const CORE_ACTIONS: &[(&str, Action, &[Mode])] = &[ ("core.insert", Action::Insert, &[Mode::Normal]), ( "core.insert_first_nonblank", Action::InsertFirstNonblank, &[Mode::Normal, Mode::Visual], ), ("core.append", Action::Append, &[Mode::Normal]), ( "core.append_line_end", Action::AppendLineEnd, &[Mode::Normal, Mode::Visual], ), ("core.open_below", Action::OpenBelow, &[Mode::Normal]), ("core.open_above", Action::OpenAbove, &[Mode::Normal]), ("core.join_lines", Action::JoinLines, &[Mode::Normal]), ("core.undo", Action::Undo, &[Mode::Normal]), ("core.redo", Action::Redo, &[Mode::Normal]), ( "core.repeat_last_edit", Action::RepeatLastEdit, &[Mode::Normal, Mode::Visual], ), ("core.command", Action::Command, &[Mode::Normal]), ("core.search", Action::Search, &[Mode::Normal]), ( "core.search_next", Action::SearchNext, &[Mode::Normal, Mode::Visual], ), ( "core.search_previous", Action::SearchPrevious, &[Mode::Normal, Mode::Visual], ), ("core.select", Action::Select, &[Mode::Normal, Mode::Visual]), ("core.select_lines", Action::SelectLines, &[Mode::Visual]), ( "core.clone_above", Action::CloneAbove, &[Mode::Normal, Mode::Visual], ), ( "core.clone_below", Action::CloneBelow, &[Mode::Normal, Mode::Visual], ), ("core.select_next", Action::SelectNext, &[Mode::Visual]), ( "core.skip_occurrence", Action::SkipOccurrence, &[Mode::Visual], ), ("core.select_all", Action::SelectAll, &[Mode::Visual]), ("core.copy", Action::Copy, &[Mode::Visual]), ("core.copy_system", Action::CopySystem, &[Mode::Visual]), ("core.cut", Action::Cut, &[Mode::Visual]), ("core.change", Action::Change, &[Mode::Visual]), ("core.paste_before", Action::PasteBefore, &[Mode::Normal]), ("core.paste_after", Action::PasteAfter, &[Mode::Normal]), ( "core.paste_system_before", Action::PasteSystemBefore, &[Mode::Normal], ), ( "core.paste_system_after", Action::PasteSystemAfter, &[Mode::Normal], ), ( "core.left", Action::Left, &[Mode::Normal, Mode::Insert, Mode::Visual], ), ( "core.right", Action::Right, &[Mode::Normal, Mode::Insert, Mode::Visual], ), ( "core.down", Action::Down, &[Mode::Normal, Mode::Insert, Mode::Visual], ), ( "core.up", Action::Up, &[Mode::Normal, Mode::Insert, Mode::Visual], ), ( "core.line_start", Action::LineStart, &[Mode::Normal, Mode::Insert, Mode::Visual], ), ( "core.line_end", Action::LineEnd, &[Mode::Normal, Mode::Insert, Mode::Visual], ), ( "core.word_next", Action::WordNext, &[Mode::Normal, Mode::Insert, Mode::Visual], ), ( "core.word_previous", Action::WordPrevious, &[Mode::Normal, Mode::Insert, Mode::Visual], ), ( "core.word_end", Action::WordEnd, &[Mode::Normal, Mode::Insert, Mode::Visual], ), ( "core.big_word_next", Action::BigWordNext, &[Mode::Normal, Mode::Insert, Mode::Visual], ), ( "core.big_word_previous", Action::BigWordPrevious, &[Mode::Normal, Mode::Insert, Mode::Visual], ), ( "core.big_word_end", Action::BigWordEnd, &[Mode::Normal, Mode::Insert, Mode::Visual], ), ("core.delete", Action::Delete, &[Mode::Normal, Mode::Insert]), ( "core.normal", Action::Normal, &[ Mode::Normal, Mode::Insert, Mode::Command, Mode::Search, Mode::Visual, ], ), ("core.newline", Action::Newline, &[Mode::Insert]), ( "core.backspace", Action::Backspace, &[Mode::Insert, Mode::Command, Mode::Search], ), ( "core.submit", Action::Submit, &[Mode::Command, Mode::Search], ), ( "core.palette", Action::Palette, &[ Mode::Normal, Mode::Insert, Mode::Command, Mode::Search, Mode::Visual, ], ), ( "core.file_picker", Action::FilePicker, &[Mode::Normal, Mode::Visual], ), ( "core.file_explorer", Action::FileExplorer, &[Mode::Normal, Mode::Visual], ), ]; impl Action { fn named(mode: Mode, name: &str) -> Option { CORE_ACTIONS .iter() .find(|(candidate, _, modes)| *candidate == name && modes.contains(&mode)) .map(|(_, action, _)| *action) } } enum Binding { Action(Action), Command(String), Disabled, } fn parse_key(key: &str) -> Result { let character = |name: &str| { let mut chars = name.chars(); chars .next() .filter(|ch| !ch.is_control() && chars.next().is_none()) }; // Keep literal punctuation, especially "+", usable without escaping. if let Some(ch) = character(key) { return Ok(Input::Char(ch)); } let invalid = || format!("Invalid binding key: {key:?}"); let (modifier, name) = key .split_once('+') .map_or((None, key), |(modifier, name)| (Some(modifier), name)); let input = if let Some(ch) = character(name) { Input::Char(ch) } else { [ ("esc", Input::Escape), ("enter", Input::Enter), ("backspace", Input::Backspace), ("delete", Input::Delete), ("left", Input::Left), ("right", Input::Right), ("up", Input::Up), ("down", Input::Down), ("home", Input::Home), ("end", Input::End), ("tab", Input::Char('\t')), ("space", Input::Char(' ')), ("minus", Input::Char('-')), ("comma", Input::Char(',')), ("ampersand", Input::Char('&')), ("plus", Input::Char('+')), ("backtick", Input::Char('`')), ] .into_iter() .find(|(key, _)| name.eq_ignore_ascii_case(key)) .map(|(_, input)| input) .ok_or_else(invalid)? }; match (modifier, input) { (None, input) => Ok(input), (Some(modifier), Input::Char(ch)) if ch.is_ascii_alphabetic() => { if modifier.eq_ignore_ascii_case("ctrl") { Ok(control_key(ch)) } else if modifier.eq_ignore_ascii_case("shift") { Ok(Input::Char(ch.to_ascii_uppercase())) } else { Err(invalid()) } } _ => Err(invalid()), } } fn control_key(ch: char) -> Input { // Terminals encode these controls as named keys. match ch.to_ascii_lowercase() { 'i' => Input::Char('\t'), 'm' => Input::Enter, ch => Input::Ctrl(ch), } } fn display_key(input: &Input) -> String { let mut display = String::new(); append_key(&mut display, input); display } fn append_key(display: &mut String, input: &Input) { use std::fmt::Write as _; match input { Input::Char('\t') => display.push_str("Tab"), Input::Char(' ') => display.push_str("Space"), Input::Char(ch) => display.push(*ch), Input::Ctrl(ch) => write!(display, "Ctrl+{ch}").expect("writing to a String"), Input::Escape => display.push_str("Esc"), Input::Enter => display.push_str("Enter"), Input::Backspace => display.push_str("Backspace"), Input::Delete => display.push_str("Delete"), Input::Left => display.push_str("Left"), Input::Right => display.push_str("Right"), Input::Up => display.push_str("Up"), Input::Down => display.push_str("Down"), Input::Home => display.push_str("Home"), Input::End => display.push_str("End"), Input::Paste(_) => unreachable!("paste cannot be bound"), } } fn default_bindings() -> HashMap> { use Action::*; let mut bindings: HashMap> = HashMap::new(); for (key, action) in [ ('i', Insert), ('I', InsertFirstNonblank), ('a', Append), ('A', AppendLineEnd), ('o', OpenBelow), ('O', OpenAbove), ('J', JoinLines), ('x', Delete), ('u', Undo), ('U', Redo), (':', Command), ('/', Search), ('p', PasteAfter), ('P', PasteBefore), ] { bindings .entry(Mode::Normal) .or_default() .insert(Input::Char(key), Binding::Action(action)); } for mode in [Mode::Normal, Mode::Visual] { for (key, action) in [ ('h', Left), ('j', Down), ('k', Up), ('l', Right), ('0', LineStart), ('$', LineEnd), ('w', WordNext), ('b', WordPrevious), ('e', WordEnd), ('W', BigWordNext), ('B', BigWordPrevious), ('E', BigWordEnd), ('n', SearchNext), ('N', SearchPrevious), ('v', Select), ('.', RepeatLastEdit), ] { bindings .entry(mode) .or_default() .insert(Input::Char(key), Binding::Action(action)); } for (key, action) in [ (Input::Ctrl('k'), CloneAbove), (Input::Ctrl('e'), CloneBelow), (Input::Escape, Normal), ] { bindings .entry(mode) .or_default() .insert(key, Binding::Action(action)); } } for mode in [Mode::Normal, Mode::Insert, Mode::Visual] { for (key, action) in [ (Input::Left, Left), (Input::Right, Right), (Input::Down, Down), (Input::Up, Up), (Input::Home, LineStart), (Input::End, LineEnd), ] { bindings .entry(mode) .or_default() .insert(key, Binding::Action(action)); } } for mode in [Mode::Normal, Mode::Insert] { bindings .entry(mode) .or_default() .insert(Input::Delete, Binding::Action(Delete)); } bindings .entry(Mode::Visual) .or_default() .insert(Input::Escape, Binding::Action(Normal)); for (key, action) in [ (Input::Char('y'), Copy), (Input::Char('d'), Cut), (Input::Char('c'), Change), (Input::Char('x'), Cut), (Input::Delete, Cut), (Input::Char('I'), InsertFirstNonblank), (Input::Char('A'), AppendLineEnd), (Input::Ctrl('l'), SelectLines), (Input::Ctrl('n'), SelectNext), (Input::Ctrl('s'), SkipOccurrence), (Input::Ctrl('a'), SelectAll), ] { bindings .entry(Mode::Visual) .or_default() .insert(key, Binding::Action(action)); } for mode in [Mode::Insert, Mode::Command, Mode::Search] { for (key, action) in [ (Input::Escape, Normal), (Input::Backspace, Backspace), ( Input::Enter, if mode == Mode::Insert { Newline } else { Submit }, ), ( Input::Ctrl('j'), if mode == Mode::Insert { Newline } else { Submit }, ), ] { bindings .entry(mode) .or_default() .insert(key, Binding::Action(action)); } } for mode in [ Mode::Normal, Mode::Insert, Mode::Command, Mode::Search, Mode::Visual, ] { bindings .entry(mode) .or_default() .insert(Input::Ctrl('p'), Binding::Action(Palette)); } bindings } /// Borrowed buffer-list metadata; paths retain their supplied display/save spelling. pub struct BufferInfo<'a> { pub id: usize, pub path: Option<&'a Path>, pub dirty: bool, pub active: bool, } /// Borrowed document state, including hidden and unnamed buffers. pub struct DocumentInfo<'a> { pub id: usize, pub revision: u64, pub path: Option<&'a Path>, pub lines: &'a [String], pub save_revision: u64, } struct AnnotationSet { revision: u64, items: Vec, } struct Buffer { id: usize, lines: Vec, saved_lines: Vec, text_revision: u64, save_revision: u64, diagnostics: BTreeMap, targets: Vec, primary: usize, mode: Mode, search_query: String, search_match: Option, path: Option, identity: Option, // ponytail: full-buffer history costs O(buffer size) per action; use edit deltas for large files. undo: Vec, redo: Vec, insert_start: Option, pending_edit: Option, visual_motions: Option>, } impl Buffer { fn open( id: usize, path: Option, identity: Option, allow_missing: bool, ) -> io::Result { let text = match &path { Some(path) => match fs::read_to_string(path) { Ok(text) => text, Err(error) if allow_missing && error.kind() == io::ErrorKind::NotFound => { String::new() } Err(error) => return Err(error), }, None => String::new(), }; validate_text(&text).map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; let lines = split_lines(&text); Ok(Self { id, saved_lines: lines.clone(), lines, text_revision: 0, save_revision: 0, diagnostics: BTreeMap::new(), targets: vec![Target::caret(Cursor::default())], primary: 0, mode: Mode::Normal, search_query: String::new(), search_match: None, path, identity, undo: Vec::new(), redo: Vec::new(), insert_start: None, pending_edit: None, visual_motions: None, }) } fn dirty(&self) -> bool { self.lines != self.saved_lines } fn info(&self, active: bool) -> BufferInfo<'_> { BufferInfo { id: self.id, path: self.path.as_deref(), dirty: self.dirty(), active, } } fn document(&self) -> DocumentInfo<'_> { DocumentInfo { id: self.id, revision: self.text_revision, path: self.path.as_deref(), lines: &self.lines, save_revision: self.save_revision, } } fn text_changed(&mut self) { self.text_revision = self.text_revision.wrapping_add(1); self.diagnostics.clear(); } } pub struct Editor { active: Buffer, parked: BTreeMap, next_buffer_id: usize, command_line: String, clipboard: Vec, clipboard_mode: ClipboardMode, system_clipboard: SystemClipboard, // Exact exported bytes bridge our fragments; external newlines never imply boundaries. owned_system_clipboard: Option<(String, Vec)>, last_edit: Option, message: String, quit: bool, capabilities: Vec, features: HashMap, bindings: HashMap>, selection_bindings: HashMap, selection_navigation_hint: String, tree_bindings: HashMap, tree_navigation_hint: String, tree_filter_navigation_hint: String, leader: Input, leader_bindings: HashMap, Binding>>, leader_suffix: Vec, leader_display: String, palette: Option, project_root: PathBuf, file_picker: Option, file_explorer_side: DockSide, file_picker_view: FileView, file_explorer_view: FileView, count: Option, } impl Editor { pub fn open(path: Option, capabilities: Vec) -> io::Result { let identity = path.as_deref().map(path_identity).transpose()?; let mut editor = Self { active: Buffer::open(1, path, identity, true)?, parked: BTreeMap::new(), next_buffer_id: 2, command_line: String::new(), clipboard: Vec::new(), clipboard_mode: ClipboardMode::System, system_clipboard: SystemClipboard::new(), owned_system_clipboard: None, last_edit: None, message: String::new(), quit: false, capabilities, features: HashMap::new(), bindings: default_bindings(), selection_bindings: [ (Input::Up, Action::Up), (Input::Ctrl('k'), Action::Up), (Input::Down, Action::Down), (Input::Ctrl('j'), Action::Down), (Input::Ctrl('n'), Action::Down), ] .into_iter() .map(|(key, action)| (key, Binding::Action(action))) .collect(), selection_navigation_hint: String::new(), tree_bindings: [ (Input::Left, Action::Left), (Input::Ctrl('h'), Action::Left), (Input::Right, Action::Right), (Input::Ctrl('l'), Action::Right), (Input::Char('h'), Action::Left), (Input::Char('j'), Action::Down), (Input::Char('k'), Action::Up), (Input::Char('l'), Action::Right), ] .into_iter() .map(|(key, action)| (key, Binding::Action(action))) .collect(), tree_navigation_hint: String::new(), tree_filter_navigation_hint: String::new(), leader: Input::Char(' '), leader_bindings: HashMap::new(), leader_suffix: Vec::new(), leader_display: String::new(), palette: None, project_root: std::env::current_dir()?, file_picker: None, file_explorer_side: DockSide::Right, file_picker_view: FileView::Tree, file_explorer_view: FileView::Tree, count: None, }; editor.refresh_selection_navigation_hint(); editor .install( Feature { name: "core".into(), required_capabilities: Vec::new(), commands: [ "w", "q", "q!", "wq", "plugins", "edit", "buffers", "buffer", "bnext", "bprev", "bdelete", "bdelete!", ] .into_iter() .map(str::to_owned) .collect(), core: true, }, true, ) .map_err(io::Error::other)?; for (mode, key, target) in [ ("normal", "leader space", "core.palette"), ("visual", "leader space", "core.palette"), ("normal", "leader o", "core.file_picker"), ("visual", "leader o", "core.file_picker"), ("normal", "leader e", "core.file_explorer"), ("visual", "leader e", "core.file_explorer"), ("normal", "leader w", "w"), ("normal", "leader b n", "bnext"), ("normal", "leader b p", "bprev"), ("normal", "leader b l", "buffers"), ("normal", "leader b d", "bdelete"), ] { editor .bind(mode, key, Some(target.into())) .map_err(io::Error::other)?; } Ok(editor) } pub fn clipboard_mode(&self) -> ClipboardMode { self.clipboard_mode } pub fn set_clipboard_mode(&mut self, mode: &str) -> Result<(), String> { self.clipboard_mode = if mode.eq_ignore_ascii_case("system") { ClipboardMode::System } else if mode.eq_ignore_ascii_case("separate") { ClipboardMode::Separate } else { return Err("Clipboard mode must be system or separate".into()); }; Ok(()) } #[cfg(test)] pub(crate) fn set_system_clipboard(&mut self, clipboard: SystemClipboard) { self.system_clipboard = clipboard; self.owned_system_clipboard = None; } /// Stable one-based ID, never reused after a buffer is deleted. pub fn buffer_id(&self) -> usize { self.active.id } pub fn buffer_count(&self) -> usize { self.parked.len() + 1 } pub fn has_buffer(&self, id: usize) -> bool { id == self.active.id || self.parked.contains_key(&id) } /// All loaded buffers in ascending ID order, without copying their paths. pub fn buffers(&self) -> impl Iterator> + '_ { self.parked .range(..self.active.id) .map(|(_, buffer)| buffer.info(false)) .chain(std::iter::once(self.active.info(true))) .chain( self.parked .range(self.active.id..) .map(|(_, buffer)| buffer.info(false)), ) } pub fn documents(&self) -> impl Iterator> + '_ { self.parked .range(..self.active.id) .map(|(_, buffer)| buffer.document()) .chain(std::iter::once(self.active.document())) .chain( self.parked .range(self.active.id..) .map(|(_, buffer)| buffer.document()), ) } pub fn project_root(&self) -> &Path { &self.project_root } fn buffer(&self, id: usize) -> Option<&Buffer> { if id == self.active.id { Some(&self.active) } else { self.parked.get(&id) } } pub fn diagnostics(&self) -> impl Iterator { self.active .diagnostics .values() .filter(|annotations| annotations.revision == self.active.text_revision) .flat_map(|annotations| &annotations.items) } pub fn diagnostic_at_cursor(&self) -> Option<&Diagnostic> { let cursor = self.cursor(); let position = TextPosition { row: cursor.row, column: cursor.column, }; self.diagnostics().find(|diagnostic| { diagnostic.start <= position && (position < diagnostic.end || (diagnostic.start == diagnostic.end && position == diagnostic.start)) }) } pub(crate) fn validate_diagnostics( &self, buffer_id: usize, revision: u64, items: &[Diagnostic], ) -> Result { let Some(buffer) = self.buffer(buffer_id) else { return Ok(false); }; if buffer.text_revision != revision { return Ok(false); } let mut bytes = 0usize; for diagnostic in items { for position in [diagnostic.start, diagnostic.end] { if buffer .lines .get(position.row) .is_none_or(|line| position.column > grapheme_count(line)) { return Err("Diagnostic position is outside the document".into()); } } if diagnostic.end < diagnostic.start { return Err("Diagnostic range is reversed".into()); } if diagnostic.message.chars().any(char::is_control) { return Err("Diagnostic messages cannot contain controls".into()); } bytes = bytes .checked_add(diagnostic.message.len()) .ok_or("Diagnostics exceed 4 MiB")?; if bytes > MAX_EDIT_BYTES { return Err("Diagnostics exceed 4 MiB".into()); } } Ok(true) } pub fn replace_diagnostics( &mut self, owner: &str, buffer_id: usize, revision: u64, items: Vec, ) -> Result { if !self.validate_diagnostics(buffer_id, revision, &items)? { return Ok(false); } Ok(self.replace_validated_diagnostics(owner, buffer_id, revision, items)) } /// Only session's fully prevalidated batch may use this storage path. pub(crate) fn replace_validated_diagnostics( &mut self, owner: &str, buffer_id: usize, revision: u64, items: Vec, ) -> bool { let buffer = if buffer_id == self.active.id { &mut self.active } else if let Some(buffer) = self.parked.get_mut(&buffer_id) { buffer } else { return false; }; if buffer.text_revision != revision { return false; } if items.is_empty() { return buffer.diagnostics.remove(owner).is_some(); } if buffer.diagnostics.get(owner).is_some_and(|annotations| { annotations.revision == revision && annotations.items == items }) { return false; } buffer .diagnostics .insert(owner.to_owned(), AnnotationSet { revision, items }); true } pub fn clear_plugin_diagnostics(&mut self, owner: &str) { self.active.diagnostics.remove(owner); for buffer in self.parked.values_mut() { buffer.diagnostics.remove(owner); } } /// Reuses loaded (including dirty) buffers and leaves the source unchanged on failure. pub fn navigate_to(&mut self, path: PathBuf, position: TextPosition) -> Result<(), String> { if path.as_os_str().is_empty() || path.to_string_lossy().chars().any(char::is_control) { return Err("Navigation path must be nonempty and contain no controls".into()); } self.edit_buffer(path, false) .map_err(|error| error.to_string())?; self.park_source(); self.active.mode = Mode::Normal; let row = position.row.min(self.active.lines.len() - 1); let column = position .column .min(grapheme_count(&self.active.lines[row]).saturating_sub(1)); self.active.targets = vec![Target::caret(Cursor { row, column })]; self.active.primary = 0; self.active.search_match = None; self.active.visual_motions = None; self.normalize_targets(); Ok(()) } pub fn lines(&self) -> &[String] { &self.active.lines } /// Changes only when this buffer's text changes, independent of cursor/view state. pub fn text_revision(&self) -> u64 { self.active.text_revision } pub fn cursor(&self) -> Cursor { self.active.targets[self.active.primary].active } pub fn mode(&self) -> Mode { self.active.mode } /// Read-only display state for the overlay; opening it does not change mode. pub fn palette(&self) -> Option<&Palette> { self.palette.as_ref() } pub fn file_picker(&self) -> Option<&FilePicker> { self.file_picker.as_ref() } pub fn file_explorer_side(&self) -> DockSide { self.file_explorer_side } pub fn set_file_explorer_side(&mut self, side: &str) -> Result<(), String> { self.file_explorer_side = match side.to_ascii_lowercase().as_str() { "left" => DockSide::Left, "right" => DockSide::Right, "top" => DockSide::Top, "bottom" => DockSide::Bottom, _ => return Err(format!("Invalid file explorer side: {side:?}")), }; Ok(()) } pub fn file_picker_view(&self) -> FileView { self.file_picker_view } pub fn file_explorer_view(&self) -> FileView { self.file_explorer_view } pub fn set_file_picker_view(&mut self, view: &str) -> Result<(), String> { self.set_file_view(view, FilePresentation::Floating) } pub fn set_file_explorer_view(&mut self, view: &str) -> Result<(), String> { self.set_file_view(view, FilePresentation::Docked) } fn set_file_view(&mut self, view: &str, presentation: FilePresentation) -> Result<(), String> { let view = match view.to_ascii_lowercase().as_str() { "list" => FileView::List, "tree" => FileView::Tree, _ => return Err(format!("Invalid file view: {view:?}")), }; match presentation { FilePresentation::Floating => self.file_picker_view = view, FilePresentation::Docked => self.file_explorer_view = view, } if let Some(picker) = self .file_picker .as_mut() .filter(|picker| picker.presentation == presentation) { picker.set_view(view); } Ok(()) } #[cfg(test)] pub(crate) fn set_project_root(&mut self, root: PathBuf) { self.project_root = root; } /// Pending decimal prefix for the next Normal/Visual motion. pub fn count(&self) -> Option { self.count } /// Actual pending leader prefix, cached until the next input. pub fn leader_pending(&self) -> Option<&str> { (!self.leader_display.is_empty()).then_some(self.leader_display.as_str()) } fn clear_leader(&mut self) { self.leader_suffix.clear(); self.leader_display.clear(); } fn cancel_leader(&mut self) { if self.leader_pending().is_some() { self.count = None; self.clear_leader(); } } /// Changes the symbolic leader without moving its bindings. pub fn set_leader(&mut self, key: &str) -> Result<(), String> { let leader = parse_key(key)?; if leader == Input::Escape || matches!(leader, Input::Char(ch) if ch.is_ascii_digit()) { return Err("Leader cannot be Escape or an ASCII digit".into()); } if leader != self.leader && [Mode::Normal, Mode::Visual].into_iter().any(|mode| { self.bindings .get(&mode) .is_some_and(|keys| keys.contains_key(&leader)) }) { return Err(format!( "Leader key already bound: {}", display_key(&leader) )); } self.cancel_leader(); self.leader = leader; self.refresh_palette(); Ok(()) } /// Editable Ex/search prompt, without its `:` or `/` prefix. pub fn command_line(&self) -> &str { &self.command_line } pub fn search_match(&self) -> Option { self.active.search_match } /// Ordered, end-exclusive Visual ranges in document order; empty in other modes. pub fn selections(&self) -> Vec { if self.active.mode == Mode::Visual { self.active .targets .iter() .map(|target| self.target_range(*target)) .collect() } else { Vec::new() } } /// All active cursors in document order, including the primary cursor. pub fn carets(&self) -> Vec { self.active .targets .iter() .map(|target| target.active) .collect() } pub fn selection_count(&self) -> usize { self.active.targets.len() } pub fn message(&self) -> &str { &self.message } pub fn path(&self) -> Option<&Path> { self.active.path.as_deref() } pub fn dirty(&self) -> bool { self.active.dirty() } pub fn should_quit(&self) -> bool { self.quit } pub fn context(&self) -> Context { Context { buffer_id: self.active.id, revision: self.active.text_revision, root: self.project_root.to_string_lossy().into_owned(), text: self.active.lines.join("\n"), row: self.cursor().row + 1, column: self.cursor().column + 1, mode: self.active.mode.to_string(), path: self .active .path .as_ref() .map(|path| path.to_string_lossy().into_owned()), } } pub fn set_message(&mut self, message: impl Into) { let message = message.into(); self.message = if message.chars().any(char::is_control) { message .chars() .map(|ch| if ch.is_control() { ' ' } else { ch }) .collect() } else { message }; } pub fn register(&mut self, feature: Feature) -> Result<(), String> { self.install(feature, false) } fn install(&mut self, feature: Feature, privileged: bool) -> Result<(), String> { if feature.core && !privileged { return Err("Plugins cannot register core features".into()); } if feature.name.trim().is_empty() || feature.name.chars().any(char::is_control) { return Err("Feature names must be nonempty and contain no controls".into()); } if self.features.contains_key(&feature.name) { return Err(format!("Feature already registered: {}", feature.name)); } for capability in &feature.required_capabilities { if !self.capabilities.contains(capability) { return Err(format!( "Feature {} requires unavailable capability: {capability}", feature.name )); } } let mut names = HashSet::new(); for command in &feature.commands { if !valid_command(command) { return Err(format!("Invalid command name: {command}")); } if !names.insert(command.as_str()) || self.owner(command).is_some() { return Err(format!("Command already registered: {command}")); } } self.features.insert(feature.name.clone(), feature); self.refresh_palette(); Ok(()) } fn owner(&self, command: &str) -> Option<&Feature> { self.features .values() .find(|feature| feature.commands.iter().any(|name| name == command)) } pub fn disable_plugin(&mut self, name: &str, reason: &str) { self.cancel_leader(); if self.features.get(name).is_some_and(|feature| feature.core) { self.set_message("Core features cannot be disabled"); return; } self.clear_plugin_diagnostics(name); if let Some(feature) = self.features.remove(name) { for bindings in self.bindings.values_mut() { bindings.retain(|_, binding| { !matches!(binding, Binding::Command(command) if feature.commands.contains(command)) }); } for bindings in self.leader_bindings.values_mut() { bindings.retain(|_, binding| { !matches!(binding, Binding::Command(command) if feature.commands.contains(command)) }); } } self.refresh_palette(); self.set_message(format!("Plugin {name} disabled: {reason}")); } /// Overrides an editing route or single selection/tree navigation key. /// `None` disables it; invalid overrides are atomic. pub fn bind(&mut self, mode: &str, key: &str, target: Option) -> Result<(), String> { if matches!(mode, "selection" | "tree") { let input = parse_key(key)?; let binding = match target.as_deref() { None => Binding::Disabled, Some("core.up") => Binding::Action(Action::Up), Some("core.down") => Binding::Action(Action::Down), Some("core.left") if mode == "tree" => Binding::Action(Action::Left), Some("core.right") if mode == "tree" => Binding::Action(Action::Right), Some(target) => return Err(format!("Unknown {mode} action: {target}")), }; if mode == "tree" { self.tree_bindings.insert(input, binding); } else { self.selection_bindings.insert(input, binding); } self.refresh_selection_navigation_hint(); return Ok(()); } let mode = match mode { "normal" => Mode::Normal, "insert" => Mode::Insert, "command" => Mode::Command, "search" => Mode::Search, "visual" => Mode::Visual, _ => return Err(format!("Unknown binding mode: {mode}")), }; let mut tokens = key.split_whitespace(); let suffix = if tokens .next() .is_some_and(|token| token.eq_ignore_ascii_case("leader")) { if !matches!(mode, Mode::Normal | Mode::Visual) { return Err("Leader sequences can only be bound in Normal or Visual mode".into()); } let suffix: Vec<_> = tokens.map(parse_key).collect::>()?; if suffix.is_empty() { return Err("Leader binding requires at least one suffix key".into()); } if self.leader_bindings.get(&mode).is_some_and(|bindings| { bindings.keys().any(|existing| { *existing != suffix && (existing.starts_with(&suffix) || suffix.starts_with(existing)) }) }) { return Err(format!("Ambiguous leader binding: {key:?}")); } Some(suffix) } else { None }; let input = if suffix.is_none() { Some(parse_key(key)?) } else { None }; let binding = match target { None => Binding::Disabled, Some(target) if target.starts_with("core.") => Binding::Action( Action::named(mode, &target) .ok_or_else(|| format!("Unknown action for {mode}: {target}"))?, ), Some(command) => { if mode != Mode::Normal { return Err("Ex/plugin commands can only be bound in Normal mode".into()); } if self.owner(&command).is_none() { return Err(format!("Unknown command: {command}")); } Binding::Command(command) } }; self.cancel_leader(); if let Some(suffix) = suffix { self.leader_bindings .entry(mode) .or_default() .insert(suffix, binding); } else { self.bindings .entry(mode) .or_default() .insert(input.expect("single-key binding"), binding); } self.refresh_palette(); Ok(()) } pub fn selection_navigation_hint(&self) -> &str { &self.selection_navigation_hint } pub fn file_navigation_hint(&self) -> &str { if let Some(picker) = &self.file_picker && picker.view == FileView::Tree { if picker.searching { &self.tree_filter_navigation_hint } else { &self.tree_navigation_hint } } else { &self.selection_navigation_hint } } fn refresh_selection_navigation_hint(&mut self) { let keys = |action, tree: bool, filtering: bool| { let applies = |input: &Input| tree && (!filtering || !matches!(input, Input::Char(_))); let mut keys: Vec<_> = self .selection_bindings .iter() .filter(|(key, _)| !applies(key) || !self.tree_bindings.contains_key(*key)) .chain(self.tree_bindings.iter().filter(|(key, _)| applies(key))) .filter( |(_, binding)| matches!(binding, Binding::Action(bound) if *bound == action), ) .map(|(input, _)| { let priority = usize::from(!(tree && !filtering && matches!(input, Input::Char(_)))); (priority, display_key(input)) }) .collect(); keys.sort(); if keys.is_empty() { "unbound".into() } else { let capacity = keys.iter().map(|(_, key)| key.len()).sum::() + keys.len() - 1; let mut hint = String::with_capacity(capacity); for (_, key) in keys { if !hint.is_empty() { hint.push('/'); } hint.push_str(&key); } hint } }; self.selection_navigation_hint = format!( "{} next · {} previous", keys(Action::Down, false, false), keys(Action::Up, false, false) ); self.tree_navigation_hint = format!( "{} next · {} previous · {} parent/close · {} open/child", keys(Action::Down, true, false), keys(Action::Up, true, false), keys(Action::Left, true, false), keys(Action::Right, true, false) ); self.tree_filter_navigation_hint = format!( "{} next · {} previous · {} parent · {} child", keys(Action::Down, true, true), keys(Action::Up, true, true), keys(Action::Left, true, true), keys(Action::Right, true, true) ); } fn handle_tree_binding(&mut self, input: &Input) -> bool { let Some(picker) = self .file_picker .as_mut() .filter(|picker| picker.focused && picker.view == FileView::Tree) else { return false; }; if picker.searching && matches!(input, Input::Char(_)) { return false; } match self.tree_bindings.get(input) { Some(Binding::Action(Action::Down)) => picker.navigate(true), Some(Binding::Action(Action::Up)) => picker.navigate(false), Some(Binding::Action(Action::Left)) => picker.left(), Some(Binding::Action(Action::Right)) => picker.right(), Some(Binding::Disabled) => {} None => return false, _ => unreachable!("tree bindings only accept navigation or disabled"), } true } fn handle_selection_binding(&mut self, input: &Input) -> bool { let next = match self.selection_bindings.get(input) { Some(Binding::Action(Action::Down)) => true, Some(Binding::Action(Action::Up)) => false, Some(Binding::Disabled) => return true, None => return false, _ => unreachable!("selection bindings only accept navigation or disabled"), }; if let Some(picker) = self.file_picker.as_mut().filter(|picker| picker.focused) { picker.navigate(next); } else if let Some(palette) = self.palette.as_mut() && !palette.entries.is_empty() { palette.selected = if next { (palette.selected + 1) % palette.entries.len() } else if palette.selected == 0 { palette.entries.len() - 1 } else { palette.selected - 1 }; } true } fn palette_bindings(&self, action: Option, command: &str) -> Vec { // ponytail: scan mode bindings per result; index by target if catalogs grow. let mut bindings: Vec<_> = self .bindings .get(&self.active.mode) .into_iter() .flat_map(|keys| keys.iter()) .filter(|(_, binding)| match (action, binding) { (Some(action), Binding::Action(bound)) => action == *bound, (None, Binding::Command(bound)) => command == bound, _ => false, }) .map(|(input, _)| display_key(input)) .collect(); if !self .bindings .get(&self.active.mode) .is_some_and(|keys| keys.contains_key(&self.leader)) { for (suffix, binding) in self .leader_bindings .get(&self.active.mode) .into_iter() .flat_map(|keys| keys.iter()) { if match (action, binding) { (Some(action), Binding::Action(bound)) => action == *bound, (None, Binding::Command(bound)) => command == bound, _ => false, } { let mut display = display_key(&self.leader); for input in suffix { display.push(' '); append_key(&mut display, input); } bindings.push(display); } } } bindings.sort(); bindings } fn refresh_palette(&mut self) { let Some(mut palette) = self.palette.take() else { return; }; let query = palette.query.to_lowercase(); // Derived labels contain the same searchable words as their names. let matches = |name: &str, owner: &str| { query .split_whitespace() .all(|token| name.contains(token) || owner.contains(token)) }; palette.entries.clear(); for &(name, action, modes) in CORE_ACTIONS { if action == Action::Palette { continue; } if !matches(name, "core") { continue; } let mut label = name.trim_start_matches("core.").replace('_', " "); label[..1].make_ascii_uppercase(); palette.entries.push(PaletteEntry { name: name.into(), label, owner: "core".into(), bindings: self.palette_bindings(Some(action), ""), enabled: modes.contains(&self.active.mode), modes: modes.to_vec(), }); } for feature in self.features.values() { let owner = feature.name.to_lowercase(); for command in &feature.commands { let name = command.to_lowercase(); if !matches(&name, &owner) { continue; } palette.entries.push(PaletteEntry { name: command.clone(), label: command.clone(), owner: feature.name.clone(), bindings: self.palette_bindings(None, command), enabled: self.active.mode == Mode::Normal, modes: vec![Mode::Normal], }); } } palette.entries.sort_by(|left, right| { left.label .cmp(&right.label) .then_with(|| left.name.cmp(&right.name)) }); palette.selected = 0; self.palette = Some(palette); } fn handle_palette(&mut self, input: Input) -> Option { let palette = self.palette.as_mut().expect("open palette receives input"); match input { Input::Escape | Input::Ctrl('p') => self.palette = None, Input::Enter => { let entry = palette .entries .get(palette.selected) .filter(|entry| entry.enabled)?; if let Some(action) = Action::named(self.active.mode, &entry.name) { self.palette = None; return self.act(action); } self.command_line = format!("{} ", entry.name); self.palette = None; if self.active.mode == Mode::Insert { self.finish_insert(); } self.active.mode = Mode::Command; } Input::Backspace => { if let Some((byte, _)) = palette.query.grapheme_indices(true).next_back() { palette.query.truncate(byte); self.refresh_palette(); } } Input::Char(ch) if !ch.is_control() => { palette.query.push(ch); self.refresh_palette(); } _ => {} } None } fn open_file_picker(&mut self, presentation: FilePresentation) { let view = match presentation { FilePresentation::Floating => self.file_picker_view, FilePresentation::Docked => self.file_explorer_view, }; if let Some(picker) = &mut self.file_picker { picker.presentation = presentation; picker.set_view(view); picker.focused = true; picker.clear_error(); } else { match FilePicker::scan(self.project_root.clone(), presentation, view) { Ok(picker) => { self.file_picker = Some(picker); } Err(error) => { self.set_message(format!("File scan failed: {error}")); return; } } } if presentation == FilePresentation::Floating && view == FileView::Tree && let Some(picker) = &mut self.file_picker && !picker.searching { picker.start_search(); } self.palette = None; self.count = None; self.clear_leader(); } fn blur_file_picker(&mut self) { match self.file_picker.as_mut() { Some(picker) if picker.presentation == FilePresentation::Docked => { picker.focused = false; } _ => self.file_picker = None, } } fn handle_file_picker(&mut self, input: Input) { let picker = self .file_picker .as_mut() .expect("focused picker receives input"); match input { Input::Ctrl('w') => self.file_picker = None, Input::Escape if picker.view == FileView::Tree && picker.searching => { picker.finish_search() } Input::Escape => self.blur_file_picker(), Input::Ctrl('p') => { self.act(Action::Palette); } Input::Char('\t') if picker.presentation == FilePresentation::Docked => { picker.focused = false; } Input::Ctrl('r') => picker.refresh(), Input::Ctrl('t') => picker.toggle_view(), Input::Ctrl('u') => picker.clear_filter(), Input::Backspace if picker.view == FileView::List || picker.searching => { picker.backspace() } Input::Char('i') if picker.view == FileView::Tree && !picker.searching => { picker.start_search() } Input::Char(ch) if !ch.is_control() && (picker.view == FileView::List || picker.searching) => { picker.query.push(ch); picker.refilter(); } Input::Enter => { let Some(path) = picker.accept() else { return }; match self.edit_buffer(path, false) { Ok(()) => self.blur_file_picker(), Err(error) => { self.file_picker .as_mut() .expect("failed open retains picker") .error = Some(format!("Open failed: {error}")); } } } _ => {} } } pub fn handle(&mut self, input: Input) -> Option { // Normalize genuine terminal aliases before list and editing dispatch. let input = match input { Input::Ctrl(ch) => control_key(ch), input => input, }; let picker_focused = self .file_picker .as_ref() .is_some_and(|picker| picker.focused); if picker_focused && self.handle_tree_binding(&input) { return None; } if (picker_focused || self.palette.is_some()) && self.handle_selection_binding(&input) { return None; } if picker_focused { self.handle_file_picker(input); return None; } if self.palette.is_some() { return self.handle_palette(input); } // Paste is literal input, never a configurable key or command. if let Input::Paste(text) = input { self.count = None; self.clear_leader(); if self.active.mode == Mode::Insert { self.paste(&text); } return None; } if self.leader_pending().is_some() { if input == Input::Escape { self.cancel_leader(); return None; } return self.handle_leader(input); } if input == Input::Escape && self.count.take().is_some() { return None; } if matches!(self.active.mode, Mode::Normal | Mode::Visual) && let Input::Char(ch @ '0'..='9') = input { let binding = self .bindings .get(&self.active.mode) .and_then(|keys| keys.get(&input)); let extends_count = ch == '0' && self.count.is_some() && matches!(binding, Some(Binding::Action(Action::LineStart))); if (binding.is_none() || extends_count) && (ch != '0' || self.count.is_some()) { let digit = usize::from(ch as u8 - b'0'); self.count = Some( self.count .unwrap_or(0) .saturating_mul(10) .saturating_add(digit), ); return None; } } match self .bindings .get(&self.active.mode) .and_then(|keys| keys.get(&input)) { Some(Binding::Action(action)) => return self.act(*action), Some(Binding::Command(command)) => { let command = command.clone(); return self.execute(&command); } Some(Binding::Disabled) => { self.count = None; return None; } None => {} } if matches!(self.active.mode, Mode::Normal | Mode::Visual) && input == self.leader { append_key(&mut self.leader_display, &input); return None; } self.count = None; match (self.active.mode, input) { (Mode::Insert, Input::Char(ch)) if !ch.is_control() || ch == '\t' => { let mut bytes = [0; 4]; self.type_native(ch.encode_utf8(&mut bytes)); } (Mode::Command | Mode::Search, Input::Char(ch)) if !ch.is_control() || (self.active.mode == Mode::Search && ch == '\t') => { self.command_line.push(ch); } _ => {} } None } fn handle_leader(&mut self, input: Input) -> Option { self.leader_display.push(' '); append_key(&mut self.leader_display, &input); self.leader_suffix.push(input); let bindings = self.leader_bindings.get(&self.active.mode); match bindings.and_then(|keys| keys.get(self.leader_suffix.as_slice())) { Some(Binding::Action(action)) => return self.act(*action), Some(Binding::Command(command)) => { let command = command.clone(); return self.execute(&command); } Some(Binding::Disabled) => { self.cancel_leader(); return None; } None => {} } // ponytail: scan finite leader routes; use a trie if catalogs grow large. if bindings.is_some_and(|keys| { keys.keys() .any(|suffix| suffix.starts_with(&self.leader_suffix)) }) { return None; } self.set_message(format!("Unknown leader sequence: {}", self.leader_display)); self.cancel_leader(); None } fn act(&mut self, action: Action) -> Option { let count = self.count.take().unwrap_or(1); self.clear_leader(); match action { Action::Palette => { self.blur_file_picker(); self.palette = Some(Palette { query: String::new(), entries: Vec::new(), selected: 0, }); self.refresh_palette(); } Action::FilePicker => self.open_file_picker(FilePresentation::Floating), Action::FileExplorer => self.open_file_picker(FilePresentation::Docked), Action::Insert | Action::InsertFirstNonblank | Action::AppendLineEnd | Action::Append | Action::OpenBelow | Action::OpenAbove => { let before = self.snapshot(); if self.active.mode == Mode::Visual { self.split_rows(matches!(action, Action::AppendLineEnd), true); } self.start_insert(before, EditStart::Insert(action)); self.place_insert(action); } Action::Left | Action::Right | Action::Down | Action::Up | Action::LineStart | Action::LineEnd | Action::WordNext | Action::WordPrevious | Action::WordEnd | Action::BigWordNext | Action::BigWordPrevious | Action::BigWordEnd => { if self.active.mode == Mode::Visual { if let Some(motions) = &mut self.active.visual_motions { motions.push((action, count)); } } else if self.active.mode == Mode::Insert { self.push_insert_step(InsertStep::Move(action, count)); } self.move_targets(action, count); } Action::JoinLines => { let before = self.snapshot(); self.join_lines(); self.record_native( before, EditRecipe { start: EditStart::Join, steps: Vec::new(), }, ); } Action::Delete => { let before = (self.active.mode != Mode::Insert).then(|| self.snapshot()); let mask = self.deletion_mask(false); self.delete_forward(&mask); if let Some(before) = before { self.record_native( before, EditRecipe { start: EditStart::Delete, steps: Vec::new(), }, ); } else if mask.iter().any(|active| *active) { self.push_insert_step(InsertStep::Delete(mask)); } } Action::Undo => self.undo(), Action::Redo => self.redo(), Action::RepeatLastEdit => self.repeat_last_edit(), Action::Command | Action::Search => { self.active.mode = if matches!(action, Action::Search) { Mode::Search } else { Mode::Command }; self.command_line.clear(); } Action::SearchNext => self.seek_search(true, count), Action::SearchPrevious => self.seek_search(false, count), Action::Select => { if self.active.mode == Mode::Visual { self.clear_selection(); } else { for target in &mut self.active.targets { target.anchor = target.active; } self.active.mode = Mode::Visual; self.active.visual_motions = Some(Vec::new()); self.normalize_targets(); self.message.clear(); } } Action::SelectLines => self.split_rows(false, false), Action::CloneAbove => self.clone_vertical(false), Action::CloneBelow => self.clone_vertical(true), Action::SelectNext | Action::SkipOccurrence | Action::SelectAll => { self.select_occurrences(action); } Action::Copy | Action::CopySystem | Action::Cut | Action::Change => { self.edit_selection(action) } Action::PasteBefore | Action::PasteAfter => self.paste_clipboard( matches!(action, Action::PasteAfter), self.clipboard_mode == ClipboardMode::System, ), Action::PasteSystemBefore | Action::PasteSystemAfter => { self.paste_clipboard(matches!(action, Action::PasteSystemAfter), true) } Action::Normal => { if self.active.mode == Mode::Normal { self.collapse_primary(); } else if self.active.mode == Mode::Insert { self.finish_insert(); self.leave_insert(); } else { self.command_line.clear(); self.active.visual_motions = None; self.active.mode = Mode::Normal; self.normalize_targets(); } } Action::Newline => self.type_native("\n"), Action::Backspace => { if self.active.mode == Mode::Insert { let mask = self.deletion_mask(true); self.backspace(&mask); if mask.iter().any(|active| *active) { self.push_insert_step(InsertStep::Backspace(mask)); } } else if let Some((byte, _)) = self.command_line.grapheme_indices(true).next_back() { self.command_line.truncate(byte); } } Action::Submit => { let command = std::mem::take(&mut self.command_line); let searching = self.active.mode == Mode::Search; self.active.mode = Mode::Normal; if searching { if !command.is_empty() { self.active.search_query = command; } self.seek_search(true, 1); } else { return self.execute(&command); } } } None } fn seek_search(&mut self, forward: bool, count: usize) { self.active.search_match = None; if self.active.search_query.is_empty() { self.set_message("No previous search"); return; } let query = self.active.search_query.as_str(); // ponytail: O(buffer graphemes × query bytes) scan; index matches if large buffers need it. let matches: Vec<_> = self .active .lines .iter() .enumerate() .flat_map(|(row, line)| { line.grapheme_indices(true).filter_map(move |(byte, _)| { (line[byte..].starts_with(query) && GraphemeCursor::new(byte + query.len(), line.len(), true) .is_boundary(line, 0) .expect("complete line supplies grapheme context")) .then_some((row, byte)) }) }) .collect(); if matches.is_empty() { self.set_message(format!("Not found: {}", self.active.search_query)); return; } for (index, target) in self.active.targets.iter_mut().enumerate() { let origin = ( target.active.row, grapheme_byte(&self.active.lines[target.active.row], target.active.column), ); let first = if forward { matches.partition_point(|position| *position <= origin) % matches.len() } else { matches .partition_point(|position| *position < origin) .checked_sub(1) .unwrap_or(matches.len() - 1) }; let distance = (count - 1) % matches.len(); let destination = if forward { (first + distance) % matches.len() } else { (first + matches.len() - distance) % matches.len() }; let found = matches[destination]; target.active = Cursor { row: found.0, column: column_after_byte(&self.active.lines[found.0], found.1), }; target.preferred_columns = None; if index == self.active.primary { self.active.search_match = Some(SearchMatch { start: target.active, end_column: target.active.column + grapheme_count(query), }); } } self.active.visual_motions = None; self.normalize_targets(); self.message.clear(); } fn park_source(&mut self) { if self.active.mode == Mode::Insert { self.finish_insert(); } if matches!( self.active.mode, Mode::Insert | Mode::Command | Mode::Search ) { self.active.mode = Mode::Normal; self.normalize_targets(); } self.command_line.clear(); self.count = None; self.clear_leader(); self.palette = None; } fn activate(&mut self, buffer: Buffer) { self.park_source(); let previous = std::mem::replace(&mut self.active, buffer); self.parked.insert(previous.id, previous); self.show_buffer(); } fn show_buffer(&mut self) { let path = self .path() .map_or_else(|| Cow::Borrowed("[No Name]"), Path::to_string_lossy); self.set_message(format!("Buffer {}: {path}", self.active.id)); } fn switch_buffer(&mut self, id: usize) { if id == self.active.id { self.park_source(); self.show_buffer(); } else if let Some(buffer) = self.parked.remove(&id) { self.activate(buffer); } else { self.set_message(format!("No buffer: {id}")); } } fn adjacent_buffer(&self, forward: bool) -> usize { use std::ops::Bound::{Excluded, Unbounded}; let id = self.active.id; if forward { self.parked .range((Excluded(id), Unbounded)) .next() .or_else(|| self.parked.first_key_value()) } else { self.parked .range(..id) .next_back() .or_else(|| self.parked.last_key_value()) } .map_or(id, |(id, _)| *id) } fn edit_buffer(&mut self, path: PathBuf, allow_missing: bool) -> io::Result<()> { let identity = path_identity(&path)?; let loaded = std::iter::once(&self.active) .chain(self.parked.values()) .find(|buffer| buffer.identity.as_ref() == Some(&identity)) .map(|buffer| buffer.id); if let Some(id) = loaded { self.switch_buffer(id); return Ok(()); } let buffer = Buffer::open( self.next_buffer_id, Some(path), Some(identity), allow_missing, )?; self.next_buffer_id += 1; self.activate(buffer); Ok(()) } fn delete_buffer(&mut self, id: usize, force: bool) { let buffer = if id == self.active.id { Some(&self.active) } else { self.parked.get(&id) }; let Some(buffer) = buffer else { self.set_message(format!("No buffer: {id}")); return; }; if buffer.dirty() && !force { self.set_message(format!( "Buffer {id} has unsaved changes; use :w or :bdelete!" )); return; } if id == self.active.id { let next = self.adjacent_buffer(true); let replacement = if next == id { let buffer = Buffer::open(self.next_buffer_id, None, None, true) .expect("an unnamed empty buffer is valid"); self.next_buffer_id += 1; buffer } else { self.parked.remove(&next).expect("adjacent buffer exists") }; self.park_source(); self.active = replacement; } else { self.parked.remove(&id); self.park_source(); } self.set_message(format!( "Deleted buffer {id}; active buffer {}", self.active.id )); } fn buffer_command(&mut self, name: &str, args: &str) -> bool { match name { "edit" if args.is_empty() => self.set_message("Usage: :edit path"), "edit" => { if let Err(error) = self.edit_buffer(PathBuf::from(args), true) { self.set_message(format!("Open failed: {error}")); } } "buffers" | "bnext" | "bprev" if !args.is_empty() => { self.set_message(format!("The {name} command takes no arguments")); } "buffers" => { let mut message = String::new(); for buffer in self.buffers() { if !message.is_empty() { message.push_str(" | "); } use std::fmt::Write as _; write!( message, "{}{}{} {}", buffer.id, if buffer.active { "%" } else { "" }, if buffer.dirty { "+" } else { "" }, buffer .path .map_or_else(|| Cow::Borrowed("[No Name]"), Path::to_string_lossy,), ) .expect("writing into a String cannot fail"); } self.set_message(message); } "bnext" | "bprev" => self.switch_buffer(self.adjacent_buffer(name == "bnext")), "buffer" | "bdelete" | "bdelete!" => { let id = if args.is_empty() && name != "buffer" { Some(self.active.id) } else if !args.is_empty() && args.bytes().all(|byte| byte.is_ascii_digit()) { args.parse::().ok().filter(|id| *id > 0) } else { None }; match id { Some(id) if name == "buffer" => self.switch_buffer(id), Some(id) => self.delete_buffer(id, name == "bdelete!"), None => self.set_message(format!( "Usage: :{name} {}", if name == "buffer" { "N" } else { "[N]" }, )), } } _ => return false, } true } fn any_dirty(&self) -> bool { self.active.dirty() || self.parked.values().any(Buffer::dirty) } pub fn execute(&mut self, command: &str) -> Option { self.count = None; self.clear_leader(); self.palette = None; self.blur_file_picker(); let command = command.trim().strip_prefix(':').unwrap_or(command.trim()); let (name, args) = command .split_once(char::is_whitespace) .unwrap_or((command, "")); let args = args.trim(); // Switching must park directed Visual selections before ordinary Ex cleanup. if self.buffer_command(name, args) { return None; } if self.active.mode == Mode::Insert { self.finish_insert(); } self.clear_selection(); self.active.mode = Mode::Normal; self.normalize_targets(); let Some(feature) = self.owner(name) else { self.set_message(format!("Unknown command: {name}")); return None; }; if !feature.core { return Some(Invocation { plugin: feature.name.clone(), command: name.into(), args: args.into(), }); } match name { "w" | "wq" => { if self.save(if args.is_empty() { None } else { Some(PathBuf::from(args)) }) && name == "wq" { if self.any_dirty() { self.set_message("Unsaved changes in other buffers; use :w or :q!"); } else { self.quit = true; } } } "q" | "q!" if !args.is_empty() => self.set_message("Quit commands take no arguments"), "q" if self.any_dirty() => self.set_message("Unsaved changes; use :w or :q!"), "q" | "q!" => self.quit = true, "plugins" if !args.is_empty() => { self.set_message("The plugins command takes no arguments") } "plugins" => { let mut features: Vec<_> = self.features.values().collect(); features.sort_unstable_by(|left, right| left.name.cmp(&right.name)); let mut message = format!("Capabilities: {}", self.capabilities.join(", ")); for feature in features { message.push_str(" | "); message.push_str(&feature.name); if feature.core { message.push_str(" [core]"); } message.push_str(": "); for (index, command) in feature.commands.iter().enumerate() { if index > 0 { message.push_str(", "); } message.push_str(command); } } self.set_message(message); } _ => unreachable!("privately installed core commands"), } None } /// Applies one validated plugin response. Successful text effects collapse /// to the primary caret: Insert runs once there, Replace replaces the buffer. /// Rejected responses and message-only effects retain the complete set. pub fn apply(&mut self, effects: Vec) -> Result<(), String> { let mut total = 0usize; for effect in &effects { let text = match effect { Effect::Insert(text) | Effect::Replace(text) | Effect::Message(text) => text, }; total = total .checked_add(text.len()) .ok_or("Plugin effects exceed 4 MiB")?; if total > MAX_EDIT_BYTES { return Err("Plugin effects exceed 4 MiB".into()); } validate_text(text)?; } // Validation precedes even history/message changes, so a rejected response is atomic. let previous_mode = self.active.mode; let inserting = self.active.mode == Mode::Insert; if inserting { self.finish_insert(); } let before = effects .iter() .any(|effect| { matches!(effect, Effect::Insert(text) if !text.is_empty()) || matches!(effect, Effect::Replace(_)) }) .then(|| self.snapshot()); if before.is_some() { self.count = None; self.clear_leader(); self.clear_selection(); self.collapse_primary(); // Plugin inserts use one primary insertion point, not the selection set. self.active.mode = Mode::Insert; } for effect in effects { match effect { Effect::Insert(text) => self.insert_text(&normalize_newlines(&text)), Effect::Replace(text) => { let lines = split_lines(&text); if lines != self.active.lines { self.active.text_changed(); } self.active.lines = lines; self.active.search_match = None; self.normalize_targets(); } Effect::Message(text) => self.set_message(text), } } if before.is_some() { self.active.mode = if inserting { Mode::Insert } else { Mode::Normal }; } self.normalize_targets(); if let Some(before) = before { self.record(before); } if inserting { self.begin_insert(); } if self.active.mode != previous_mode { self.refresh_palette(); } Ok(()) } fn save(&mut self, path: Option) -> bool { let Some(path) = path.or_else(|| self.active.path.clone()) else { self.set_message("No file name; use :w path"); return false; }; let identity = match path_identity(&path) { Ok(identity) => identity, Err(error) => { self.set_message(format!("Write failed: {error}")); return false; } }; if let Some(buffer) = self .parked .values() .find(|buffer| buffer.identity.as_ref() == Some(&identity)) { self.set_message(format!( "Write refused: path belongs to buffer {}", buffer.id )); return false; } match atomic_save(&path, &self.active.lines) { Ok(()) => { self.active.saved_lines.clone_from(&self.active.lines); self.active.save_revision = self.active.save_revision.wrapping_add(1); self.active.path = Some(path); self.active.identity = Some(identity); self.set_message("Written"); true } Err(error) => { self.set_message(format!("Write failed: {error}")); false } } } fn snapshot(&self) -> Snapshot { Snapshot { lines: self.active.lines.clone(), targets: self.active.targets.clone(), primary: self.active.primary, } } fn record(&mut self, before: Snapshot) { if before.lines != self.active.lines { self.active.search_match = None; self.active.undo.push(before); self.active.redo.clear(); } } fn record_native(&mut self, before: Snapshot, recipe: EditRecipe) { if before.lines != self.active.lines { self.last_edit = Some(recipe); } self.record(before); } fn target_range(&self, target: Target) -> Selection { let start = target.anchor.min(target.active); let end = self.next_boundary(target.anchor.max(target.active)); Selection { start, end } } fn next_boundary(&self, mut cursor: Cursor) -> Cursor { if cursor.column < grapheme_count(&self.active.lines[cursor.row]) { cursor.column += 1; } else if cursor.row + 1 < self.active.lines.len() { cursor.row += 1; cursor.column = 0; } cursor } fn previous_boundary(&self, mut cursor: Cursor) -> Cursor { if cursor.column > 0 { cursor.column -= 1; } else if cursor.row > 0 { cursor.row -= 1; cursor.column = grapheme_count(&self.active.lines[cursor.row]); } cursor } fn range_target(&self, range: Selection, forward: bool) -> Target { let last = if range.start == range.end { range.start } else { self.previous_boundary(range.end) }; Target { anchor: if forward { range.start } else { last }, active: if forward { last } else { range.start }, preferred_columns: None, } } fn max_column(&self, row: usize) -> usize { let count = grapheme_count(&self.active.lines[row]); if self.active.mode == Mode::Insert || (self.active.mode == Mode::Visual && row + 1 < self.active.lines.len()) { count } else { count.saturating_sub(1) } } fn normalize_targets(&mut self) { for index in 0..self.active.targets.len() { let mut target = self.active.targets[index]; target.active.row = target.active.row.min(self.active.lines.len() - 1); target.active.column = target.active.column.min(self.max_column(target.active.row)); if self.active.mode == Mode::Visual { target.anchor.row = target.anchor.row.min(self.active.lines.len() - 1); target.anchor.column = target.anchor.column.min(self.max_column(target.anchor.row)); } else { target.anchor = target.active; } self.active.targets[index] = target; } let ordered: Vec<_> = self .active .targets .iter() .copied() .enumerate() .map(|(index, target)| { let range = if self.active.mode == Mode::Visual { self.target_range(target) } else { Selection { start: target.active, end: target.active, } }; (target, index == self.active.primary, range) }) .collect(); self.merge_target_ranges(ordered, false); } fn merge_target_ranges( &mut self, mut ordered: Vec<(Target, bool, Selection)>, keep_ranges: bool, ) -> Vec { let count = ordered.len(); ordered.sort_by_key(|(_, _, range)| (range.start, range.end)); let mut merged: Vec<(Target, bool, Selection)> = Vec::with_capacity(ordered.len()); for (target, primary, range) in ordered { if let Some((previous, was_primary, previous_range)) = merged.last_mut() && ranges_intersect(*previous_range, range) { let representative = if primary { target } else { *previous }; previous_range.start = previous_range.start.min(range.start); previous_range.end = previous_range.end.max(range.end); *previous = if self.active.mode == Mode::Visual && self.target_range(representative) != *previous_range { self.range_target( *previous_range, representative.active >= representative.anchor, ) } else { representative }; *was_primary |= primary; continue; } merged.push((target, primary, range)); } if merged.len() != count { self.active.visual_motions = None; } let ranges = if keep_ranges { merged.iter().map(|(_, _, range)| *range).collect() } else { Vec::new() }; self.active.primary = merged .iter() .position(|(_, primary, _)| *primary) .unwrap_or(0); self.active.targets.clear(); self.active .targets .extend(merged.into_iter().map(|(target, _, _)| target)); ranges } fn collapse_primary(&mut self) { let target = self.active.targets[self.active.primary]; self.active.targets.clear(); self.active.targets.push(target); self.active.primary = 0; } fn clear_selection(&mut self) { if self.active.mode == Mode::Visual { self.active.visual_motions = None; self.active.mode = Mode::Normal; self.normalize_targets(); } } fn split_rows(&mut self, end: bool, insert: bool) { self.active.visual_motions = None; let ranges = self.selections(); let primary_row = self.cursor().row; let mut carets = Vec::new(); let mut primary = 0; for (index, range) in ranges.into_iter().enumerate() { let last = if range.end.column == 0 && range.end.row > range.start.row { range.end.row - 1 } else { range.end.row }; for row in range.start.row..=last { if index == self.active.primary && row == primary_row { primary = carets.len(); } let column = if end { grapheme_count(&self.active.lines[row]) } else { first_nonblank(&self.active.lines[row]) }; carets.push(Target::caret(Cursor { row, column })); } } self.active.targets = carets; self.active.primary = primary; self.active.mode = if insert { Mode::Insert } else { Mode::Normal }; self.normalize_targets(); } fn clone_vertical(&mut self, down: bool) { self.active.visual_motions = None; let count = self.active.targets.len(); let primary = self.active.primary; for index in 0..count { let target = self.active.targets[index]; let shift = |row: usize| { if down { row.checked_add(1) .filter(|row| *row < self.active.lines.len()) } else { row.checked_sub(1) } }; let Some(anchor_row) = shift(target.anchor.row) else { continue; }; let Some(active_row) = shift(target.active.row) else { continue; }; let columns = target.preferred_columns.unwrap_or_else(|| { ( display_column(&self.active.lines[target.anchor.row], target.anchor.column), display_column(&self.active.lines[target.active.row], target.active.column), ) }); self.active.targets[index].preferred_columns = Some(columns); let cloned = Target { anchor: Cursor { row: anchor_row, column: column_at_display( &self.active.lines[anchor_row], columns.0, self.max_column(anchor_row), ), }, active: Cursor { row: active_row, column: column_at_display( &self.active.lines[active_row], columns.1, self.max_column(active_row), ), }, preferred_columns: Some(columns), }; if index == primary { self.active.primary = self.active.targets.len(); } self.active.targets.push(cloned); } self.normalize_targets(); } fn select_occurrences(&mut self, action: Action) { let text = self.active.lines.join("\n"); let offsets = line_offsets(&self.active.lines); let ranges = self.selections(); let primary_range = ranges[self.active.primary]; let start = cursor_offset(&self.active.lines, &offsets, primary_range.start); let end = cursor_offset(&self.active.lines, &offsets, primary_range.end); if start == end { self.set_message("Nothing selected"); return; } let phrase = &text[start..end]; let mut occupied: Vec<_> = ranges .iter() .map(|range| { ( cursor_offset(&self.active.lines, &offsets, range.start), cursor_offset(&self.active.lines, &offsets, range.end), ) }) .collect(); // ponytail: literal scan is O(buffer graphemes × phrase bytes); index only for large buffers. let candidates: Vec<_> = text .grapheme_indices(true) .filter_map(|(byte, _)| { let end = byte + phrase.len(); (text[byte..].starts_with(phrase) && GraphemeCursor::new(end, text.len(), true) .is_boundary(&text, 0) .expect("complete buffer supplies grapheme context")) .then_some((byte, end)) }) .collect(); if matches!(action, Action::SelectAll) { for (start, end) in candidates { if occupied .iter() .any(|(left, right)| start < *right && *left < end) { continue; } occupied.push((start, end)); self.active.targets.push(self.range_target( Selection { start: offset_cursor(&self.active.lines, &offsets, start, true), end: offset_cursor(&self.active.lines, &offsets, end, true), }, true, )); } } else { let available = |(left, right): &(usize, usize)| { !occupied .iter() .any(|(start, end)| *left < *end && *start < *right) }; let next = candidates .iter() .filter(|range| available(range)) .find(|(left, _)| *left > start) .or_else(|| candidates.iter().find(|range| available(range))); let Some(&(start, end)) = next else { self.set_message("No more occurrences"); return; }; let target = self.range_target( Selection { start: offset_cursor(&self.active.lines, &offsets, start, true), end: offset_cursor(&self.active.lines, &offsets, end, true), }, true, ); if matches!(action, Action::SkipOccurrence) { self.active.targets[self.active.primary] = target; } else { self.active.primary = self.active.targets.len(); self.active.targets.push(target); } } self.active.visual_motions = None; self.normalize_targets(); self.message.clear(); } fn edit_selection(&mut self, action: Action) { let ranges = self.selections(); let fragments = self.selection_fragments(&ranges); let nonempty = fragments.iter().any(|fragment| !fragment.is_empty()); if let Err(error) = self.store_clipboard( fragments, matches!(action, Action::CopySystem) || self.clipboard_mode == ClipboardMode::System, ) { self.set_message(error); return; } let cut = matches!(action, Action::Cut | Action::Change); let changing = matches!(action, Action::Change); let recipe = cut.then(|| EditRecipe { start: EditStart::Selection { change: changing, selector: self.selection_intent(&ranges), }, steps: Vec::new(), }); let before = cut.then(|| self.snapshot()); if changing { self.start_insert(before.unwrap(), recipe.unwrap().start); self.edit_ranges(action, &ranges); self.message.clear(); return; } self.edit_ranges(action, &ranges); self.active.mode = Mode::Normal; self.normalize_targets(); if let Some(before) = before { self.record_native(before, recipe.unwrap()); } self.set_message(if !nonempty { "Nothing selected" } else if cut { "Cut selections" } else { "Copied selections" }); } fn selection_fragments(&self, ranges: &[Selection]) -> Vec { let offsets = line_offsets(&self.active.lines); ranges .iter() .map(|range| { let start = cursor_offset(&self.active.lines, &offsets, range.start); let end = cursor_offset(&self.active.lines, &offsets, range.end); let mut fragment = String::with_capacity(end - start); for (row, &offset) in offsets .iter() .enumerate() .take(range.end.row + 1) .skip(range.start.row) { if row != range.start.row { fragment.push('\n'); } let line = &self.active.lines[row]; let start = if row == range.start.row { start - offset } else { 0 }; let end = if row == range.end.row { end - offset } else { line.len() }; fragment.push_str(&line[start..end]); } fragment }) .collect() } fn store_clipboard(&mut self, fragments: Vec, system: bool) -> Result<(), String> { if fragments.iter().all(|fragment| fragment.is_empty()) { return Ok(()); } if system { let exported = fragments.join("\n"); self.system_clipboard.write(&exported)?; self.owned_system_clipboard = Some((exported, fragments)); } else { self.clipboard = fragments; } Ok(()) } // Range mutation has no register/history side effects: replay publishes only after validation. fn edit_ranges(&mut self, action: Action, ranges: &[Selection]) { let cut = matches!(action, Action::Cut | Action::Change); self.active.visual_motions = None; let offsets = line_offsets(&self.active.lines); self.active.mode = Mode::Insert; if cut { let edits = ranges .iter() .enumerate() .map(|(target, range)| Edit { target, start: cursor_offset(&self.active.lines, &offsets, range.start), end: cursor_offset(&self.active.lines, &offsets, range.end), text: Cow::Borrowed(""), caret: 0, round_up: true, }) .collect(); self.apply_edits(edits); } else { for (target, range) in self.active.targets.iter_mut().zip(ranges) { target.active = range.start; } } } fn read_system_clipboard(&mut self) -> Result, String> { let text = self.system_clipboard.read()?; validate_paste_text(&text)?; if let Some((exported, fragments)) = &self.owned_system_clipboard && *exported == text { return Ok(fragments.clone()); } self.owned_system_clipboard = None; Ok(if text.is_empty() { Vec::new() } else { vec![normalize_newlines(&text).into_owned()] }) } fn paste_clipboard(&mut self, after: bool, system: bool) { let mut fragments = if system { match self.read_system_clipboard() { Ok(fragments) => fragments, Err(error) => { self.set_message(error); return; } } } else { std::mem::take(&mut self.clipboard) }; if fragments.is_empty() { self.set_message("Clipboard is empty"); } else if fragments.len() != 1 && fragments.len() != self.active.targets.len() { self.set_message("Clipboard fragment count does not match caret count"); } else { let before = self.snapshot(); self.paste_fragments(after, &fragments); if before.lines != self.active.lines { self.last_edit = Some(EditRecipe { start: EditStart::Paste { after, fragments: if system { std::mem::take(&mut fragments) } else { fragments.clone() }, }, steps: Vec::new(), }); } self.record(before); self.message.clear(); } if !system { self.clipboard = fragments; } } fn paste_fragments(&mut self, after: bool, fragments: &[String]) { let offsets = line_offsets(&self.active.lines); let edits = self .active .targets .iter() .enumerate() .map(|(target, item)| { let mut cursor = item.active; if after { cursor.column = (cursor.column + 1).min(grapheme_count(&self.active.lines[cursor.row])); } let start = cursor_offset(&self.active.lines, &offsets, cursor); let text = &fragments[if fragments.len() == 1 { 0 } else { target }]; Edit { target, start, end: start, text: Cow::Borrowed(text), caret: text.len(), round_up: true, } }) .collect(); self.active.mode = Mode::Insert; self.apply_edits(edits); self.leave_insert(); } fn begin_insert(&mut self) { self.start_insert(self.snapshot(), EditStart::Insert(Action::Insert)); } fn start_insert(&mut self, before: Snapshot, start: EditStart) { self.active.insert_start = Some(before); self.active.pending_edit = Some(EditRecipe { start, steps: Vec::new(), }); self.active.visual_motions = None; self.active.mode = Mode::Insert; } fn finish_insert(&mut self) { let recipe = self.active.pending_edit.take(); if let Some(before) = self.active.insert_start.take() { if let Some(mut recipe) = recipe { while matches!(recipe.steps.last(), Some(InsertStep::Move(_, _))) { recipe.steps.pop(); } self.record_native(before, recipe); } else { self.record(before); } } } fn leave_insert(&mut self) { for target in &mut self.active.targets { target.active.column = target.active.column.saturating_sub(1); } self.active.mode = Mode::Normal; self.normalize_targets(); } fn place_insert(&mut self, action: Action) { self.active.mode = Mode::Insert; if matches!(action, Action::OpenBelow | Action::OpenAbove) { self.open_lines(matches!(action, Action::OpenBelow)); return; } for target in &mut self.active.targets { let line = &self.active.lines[target.active.row]; target.active.column = match action { Action::InsertFirstNonblank => first_nonblank(line), Action::AppendLineEnd => grapheme_count(line), Action::Append => (target.active.column + 1).min(grapheme_count(line)), _ => target.active.column, }; } self.normalize_targets(); } fn push_insert_step(&mut self, step: InsertStep) { if let Some(recipe) = &mut self.active.pending_edit { recipe.steps.push(step); } } fn type_native(&mut self, text: &str) { self.insert_text(text); if let Some(recipe) = &mut self.active.pending_edit { if let Some(InsertStep::Typed(typed)) = recipe.steps.last_mut() { typed.push_str(text); } else { recipe.steps.push(InsertStep::Typed(text.to_owned())); } } } fn deletion_mask(&self, backwards: bool) -> Vec { let mut mask: Vec<_> = self .active .targets .iter() .map(|target| { let cursor = target.active; if backwards { cursor.row != 0 || cursor.column != 0 } else { cursor.column < grapheme_count(&self.active.lines[cursor.row]) || (self.active.mode == Mode::Insert && cursor.row + 1 < self.active.lines.len()) } }) .collect(); if mask.iter().all(|active| *active) { mask.truncate(1); } mask } fn selection_intent(&mut self, ranges: &[Selection]) -> SelectionIntent { if let Some(motions) = self.active.visual_motions.take() { return SelectionIntent::Motions(motions); } let mut spans: Vec<_> = ranges .iter() .map(|range| { let mut cursor = range.start; let mut span = 0; while cursor < range.end { cursor = self.next_boundary(cursor); span += 1; } span }) .collect(); if spans.iter().all(|span| *span == spans[0]) { spans.truncate(1); } SelectionIntent::Spans(spans) } fn repeat_last_edit(&mut self) { let Some(recipe) = self.last_edit.take() else { self.set_message("Nothing to repeat"); return; }; let cardinality = match &recipe.start { EditStart::Paste { fragments, .. } => Some(fragments.len()), EditStart::Selection { selector: SelectionIntent::Spans(spans), .. } if self.active.mode != Mode::Visual => Some(spans.len()), _ => None, }; if cardinality.is_some_and(|count| count != 1 && count != self.active.targets.len()) { self.set_message("Repeat fragment/span count does not match target count"); self.last_edit = Some(recipe); return; } let before = self.snapshot(); let mode = self.active.mode; let selector = self.active.visual_motions.take(); let search_match = self.active.search_match; let result = self .replay_edit(&recipe) .map_err(str::to_owned) .and_then(|fragments| { if let Some(fragments) = fragments { self.store_clipboard(fragments, self.clipboard_mode == ClipboardMode::System)?; } Ok(()) }); match result { Ok(()) => { self.active.visual_motions = None; self.record(before); self.message.clear(); } Err(error) => { self.active.lines = before.lines; self.active.targets = before.targets; self.active.primary = before.primary; self.active.mode = mode; self.active.visual_motions = selector; self.active.search_match = search_match; self.set_message(error); } } self.last_edit = Some(recipe); } fn replay_edit(&mut self, recipe: &EditRecipe) -> Result>, &'static str> { let mut clipboard = None; match &recipe.start { EditStart::Insert(action) => self.place_insert(*action), EditStart::Selection { change, selector } => { let ranges = self.replay_ranges(selector); clipboard = Some(self.selection_fragments(&ranges)); self.edit_ranges(if *change { Action::Change } else { Action::Cut }, &ranges); if !*change { self.active.mode = Mode::Normal; self.normalize_targets(); } } EditStart::Delete => { self.active.mode = Mode::Normal; self.delete_forward(&[true]); } EditStart::Join => { self.active.mode = Mode::Normal; self.join_lines(); } EditStart::Paste { after, fragments } => self.paste_fragments(*after, fragments), } for step in &recipe.steps { match step { InsertStep::Typed(text) => { // Scalar boundaries matter when an insertion merges with its right neighbor. for ch in text.chars() { let mut bytes = [0; 4]; self.insert_text(ch.encode_utf8(&mut bytes)); } } InsertStep::Bulk(text) => self.insert_text(text), InsertStep::Move(action, count) => self.move_targets(*action, *count), InsertStep::Delete(mask) | InsertStep::Backspace(mask) => { // Earlier native steps may have merged carets. Refuse the entire transaction // rather than pairing a recorded partial deletion with different targets. if mask.len() != 1 && mask.len() != self.active.targets.len() { return Err("Repeat deletion count does not match target count"); } if matches!(step, InsertStep::Delete(_)) { self.delete_forward(mask); } else { self.backspace(mask); } } } } if self.active.mode == Mode::Insert { self.leave_insert(); } else { self.active.mode = Mode::Normal; self.normalize_targets(); } Ok(clipboard) } fn replay_ranges(&mut self, selector: &SelectionIntent) -> Vec { if self.active.mode == Mode::Visual { return self.selections(); } self.active.mode = Mode::Visual; match selector { SelectionIntent::Motions(motions) => { for target in &mut self.active.targets { target.anchor = target.active; } for (action, count) in motions { self.move_targets(*action, *count); } self.normalize_targets(); self.selections() } SelectionIntent::Spans(spans) => { let ordered = self .active .targets .iter() .enumerate() .map(|(index, target)| { let start = target.active; let mut end = start; for _ in 0..spans[if spans.len() == 1 { 0 } else { index }] { let next = self.next_boundary(end); if next == end { break; } end = next; } let range = Selection { start, end }; ( self.range_target(range, true), index == self.active.primary, range, ) }) .collect(); self.merge_target_ranges(ordered, true) } } } fn paste(&mut self, text: &str) { if let Err(error) = validate_paste_text(text) { self.set_message(error); return; } // Preserve the separate typing / paste / typing undo groups. self.finish_insert(); let text = normalize_newlines(text); if !text.is_empty() { let before = self.snapshot(); self.insert_text(&text); self.record_native( before, EditRecipe { start: EditStart::Insert(Action::Insert), steps: vec![InsertStep::Bulk(text.into_owned())], }, ); } self.begin_insert(); } fn restore(&mut self, snapshot: Snapshot) { if self.active.lines != snapshot.lines { self.active.text_changed(); } self.active.lines = snapshot.lines; self.active.targets = snapshot.targets; self.active.primary = snapshot.primary; self.active.mode = Mode::Normal; self.active.search_match = None; self.active.visual_motions = None; self.normalize_targets(); } fn undo(&mut self) { if let Some(before) = self.active.undo.pop() { self.active.redo.push(self.snapshot()); self.restore(before); } else { self.set_message("Nothing to undo"); } } fn redo(&mut self) { if let Some(after) = self.active.redo.pop() { self.active.undo.push(self.snapshot()); self.restore(after); } else { self.set_message("Nothing to redo"); } } fn move_targets(&mut self, action: Action, count: usize) { for index in 0..self.active.targets.len() { let mut cursor = self.active.targets[index].active; match action { Action::Left => cursor.column = cursor.column.saturating_sub(count), Action::Right => { cursor.column = cursor .column .saturating_add(count) .min(self.max_column(cursor.row)) } Action::Down | Action::Up => { let row = if matches!(action, Action::Down) { cursor .row .saturating_add(count) .min(self.active.lines.len() - 1) } else { cursor.row.saturating_sub(count) }; let first = cursor.row.min(row); let last = cursor.row.max(row); cursor.column = (first..=last) .map(|row| self.max_column(row)) .min() .unwrap_or(0) .min(cursor.column); cursor.row = row; } Action::LineStart => cursor.column = 0, Action::LineEnd => cursor.column = self.max_column(cursor.row), _ => cursor = self.word_destination(cursor, action, count), } self.active.targets[index].active = cursor; self.active.targets[index].preferred_columns = None; } self.normalize_targets(); } fn word_destination(&self, cursor: Cursor, action: Action, count: usize) -> Cursor { let row = cursor.row; let byte = grapheme_byte(&self.active.lines[row], cursor.column); let big = matches!( action, Action::BigWordNext | Action::BigWordPrevious | Action::BigWordEnd ); let destination = match action { Action::WordPrevious | Action::BigWordPrevious => { let mut tokens = word_tokens(&self.active.lines[..=row], big) .rev() .skip_while(|(r, b, _)| (*r, *b) >= (row, byte)) .peekable(); let mut destination = (0, 0); for _ in 0..count { let Some(mut first) = tokens.find(|(_, _, kind)| *kind != WordKind::Whitespace) else { destination = (0, 0); break; }; if first.2 != WordKind::EmptyLine { while tokens.peek().is_some_and(|(_, _, kind)| *kind == first.2) { first = tokens.next().expect("peeked token"); } } destination = (first.0, first.1); } destination } _ => { let mut tokens = word_tokens(&self.active.lines[row..], big) .skip_while(|(r, b, _)| (*r, *b) < (0, byte)) .peekable(); let mut current = tokens .next() .expect("cursor has a grapheme or line-end token") .2; let last = self.active.lines.len() - 1; let mut destination = (last, self.active.lines[last].len()); for _ in 0..count { let next_start = matches!(action, Action::WordNext | Action::BigWordNext); if next_start && current != WordKind::EmptyLine { while tokens.peek().is_some_and(|(_, _, kind)| *kind == current) { tokens.next(); } } let Some(mut first) = tokens.find(|(_, _, kind)| { *kind != WordKind::Whitespace && (next_start || *kind != WordKind::EmptyLine) }) else { destination = (last, self.active.lines[last].len()); break; }; current = first.2; if !next_start { while tokens.peek().is_some_and(|(_, _, kind)| *kind == current) { first = tokens.next().expect("peeked token"); } } destination = (row + first.0, first.1); } destination } }; let line = &self.active.lines[destination.0]; let column = column_after_byte(line, destination.1); Cursor { row: destination.0, column: if destination.1 == line.len() && self.active.mode != Mode::Insert { column.saturating_sub(1) } else { column }, } } /// Every edit in a batch refers to this one pre-edit buffer. Duplicate row /// operations share an edit; caret destinations are remapped only afterwards. // ponytail: rebuilding text costs O(buffer + carets²); use per-line edits for large buffers. fn apply_edits(&mut self, mut edits: Vec>) { if edits.is_empty() { return; } edits.sort_by_key(|edit| (edit.start, edit.end)); let mut unique: Vec = Vec::with_capacity(edits.len()); for (index, edit) in edits.iter().enumerate() { if unique.last().is_some_and(|previous| { let previous = &edits[*previous]; previous.start == edit.start && previous.end == edit.end && previous.text == edit.text }) { continue; } unique.push(index); } let old = self.active.lines.join("\n"); let offsets = line_offsets(&self.active.lines); let removed: usize = unique .iter() .map(|index| edits[*index].end - edits[*index].start) .sum(); let added: usize = unique.iter().map(|index| edits[*index].text.len()).sum(); let mut text = String::with_capacity(old.len() - removed + added); let mut consumed = 0; for index in &unique { let edit = &edits[*index]; assert!(edit.start >= consumed, "batch edits must not overlap"); text.push_str(&old[consumed..edit.start]); text.push_str(&edit.text); consumed = edit.end; } text.push_str(&old[consumed..]); let remap = |position: usize, after: bool| { let mut removed = 0; let mut added = 0; for index in &unique { let edit = &edits[*index]; if position < edit.start { break; } if position == edit.start || position < edit.end { return edit.start - removed + added + if after { edit.text.len() } else { 0 }; } removed += edit.end - edit.start; added += edit.text.len(); } position - removed + added }; let mut destinations: Vec<_> = self .active .targets .iter() .map(|target| { ( remap( cursor_offset(&self.active.lines, &offsets, target.active), true, ), true, ) }) .collect(); for edit in &edits { destinations[edit.target] = (remap(edit.start, false) + edit.caret, edit.round_up); } if text != old { self.active.text_changed(); self.active.search_match = None; } self.active.lines = text.split('\n').map(str::to_owned).collect(); let offsets = line_offsets(&self.active.lines); for (target, (byte, round_up)) in self.active.targets.iter_mut().zip(destinations) { target.active = offset_cursor(&self.active.lines, &offsets, byte, round_up); target.anchor = target.active; target.preferred_columns = None; } self.normalize_targets(); } fn insert_text(&mut self, text: &str) { if text.is_empty() { return; } let offsets = line_offsets(&self.active.lines); let edits = self .active .targets .iter() .enumerate() .map(|(target, item)| { let start = cursor_offset(&self.active.lines, &offsets, item.active); Edit { target, start, end: start, text: Cow::Borrowed(text), caret: text.len(), round_up: true, } }) .collect(); self.apply_edits(edits); } fn open_lines(&mut self, below: bool) { let offsets = line_offsets(&self.active.lines); let edits = self .active .targets .iter() .enumerate() .map(|(target, item)| { let start = offsets[item.active.row] + if below { self.active.lines[item.active.row].len() } else { 0 }; Edit { target, start, end: start, text: Cow::Borrowed("\n"), caret: usize::from(below), round_up: true, } }) .collect(); self.apply_edits(edits); } fn delete_forward(&mut self, mask: &[bool]) { let offsets = line_offsets(&self.active.lines); let edits = self .active .targets .iter() .enumerate() .filter_map(|(target, item)| { if !mask[if mask.len() == 1 { 0 } else { target }] { return None; } let cursor = item.active; let start = cursor_offset(&self.active.lines, &offsets, cursor); let end = if cursor.column < grapheme_count(&self.active.lines[cursor.row]) { cursor_offset(&self.active.lines, &offsets, self.next_boundary(cursor)) } else if self.active.mode == Mode::Insert && cursor.row + 1 < self.active.lines.len() { start + 1 } else { return None; }; Some(Edit { target, start, end, text: Cow::Borrowed(""), caret: 0, round_up: true, }) }) .collect(); self.apply_edits(edits); } fn join_lines(&mut self) { let offsets = line_offsets(&self.active.lines); let edits = self .active .targets .iter() .enumerate() .filter_map(|(target, item)| { let row = item.active.row; let line = &self.active.lines[row]; let next = self.active.lines.get(row + 1)?; let trimmed = next.trim_start_matches([' ', '\t']); let separator = if !line.is_empty() && !trimmed.is_empty() && !line.ends_with([' ', '\t']) && !trimmed.starts_with(')') { if line.ends_with(['.', '?', '!']) { " " } else { " " } } else { "" }; let start = offsets[row] + line.len(); Some(Edit { target, start, end: start + 1 + next.len() - trimmed.len(), text: Cow::Borrowed(separator), caret: 0, round_up: false, }) }) .collect(); self.apply_edits(edits); } fn backspace(&mut self, mask: &[bool]) { let offsets = line_offsets(&self.active.lines); let edits = self .active .targets .iter() .enumerate() .filter_map(|(target, item)| { if !mask[if mask.len() == 1 { 0 } else { target }] { return None; } let cursor = item.active; if cursor.column == 0 && cursor.row == 0 { return None; } let start = cursor_offset(&self.active.lines, &offsets, self.previous_boundary(cursor)); let end = cursor_offset(&self.active.lines, &offsets, cursor); Some(Edit { target, start, end, text: Cow::Borrowed(""), caret: 0, round_up: true, }) }) .collect(); self.apply_edits(edits); } } fn ranges_intersect(left: Selection, right: Selection) -> bool { if left.start == left.end { left.start == right.start || (right.start < left.start && left.start < right.end) } else if right.start == right.end { left.start <= right.start && right.start < left.end } else { left.start < right.end && right.start < left.end } } fn first_nonblank(line: &str) -> usize { line.graphemes(true) .take_while(|grapheme| matches!(*grapheme, " " | "\t")) .count() } fn display_column(line: &str, column: usize) -> usize { line.graphemes(true) .take(column) .fold(0, |cells, grapheme| { cells + if grapheme == "\t" { 4 - cells % 4 } else { grapheme.width() } }) } fn column_at_display(line: &str, wanted: usize, maximum: usize) -> usize { let mut cells = 0; let mut count = 0; for (column, grapheme) in line.graphemes(true).enumerate() { let width = if grapheme == "\t" { 4 - cells % 4 } else { grapheme.width() }; if cells + width > wanted { return column.min(maximum); } cells += width; count = column + 1; } count.min(maximum) } fn line_offsets(lines: &[String]) -> Vec { let mut byte = 0; lines .iter() .map(|line| { let start = byte; byte += line.len() + 1; start }) .collect() } fn cursor_offset(lines: &[String], offsets: &[usize], cursor: Cursor) -> usize { offsets[cursor.row] + grapheme_byte(&lines[cursor.row], cursor.column) } fn offset_cursor(lines: &[String], offsets: &[usize], byte: usize, round_up: bool) -> Cursor { let row = offsets .partition_point(|offset| *offset <= byte) .saturating_sub(1); let local = (byte - offsets[row]).min(lines[row].len()); let column = if round_up { column_after_byte(&lines[row], local) } else { lines[row] .grapheme_indices(true) .take_while(|(byte, _)| *byte <= local) .count() .saturating_sub(1) }; Cursor { row, column } } #[derive(Clone, Copy, Eq, PartialEq)] enum WordKind { Whitespace, Keyword, Punctuation, EmptyLine, } fn word_tokens( lines: &[String], big: bool, ) -> impl DoubleEndedIterator + '_ { lines.iter().enumerate().flat_map(move |(row, line)| { line.grapheme_indices(true) .map(move |(byte, grapheme)| { let ch = grapheme.chars().next().expect("graphemes are nonempty"); let kind = if ch.is_whitespace() { WordKind::Whitespace } else if big || ch == '_' || ch.is_alphanumeric() { WordKind::Keyword } else { WordKind::Punctuation }; (row, byte, kind) }) .chain(std::iter::once(( row, line.len(), if line.is_empty() { WordKind::EmptyLine } else { WordKind::Whitespace }, ))) }) } fn valid_command(command: &str) -> bool { let command = command.strip_suffix('!').unwrap_or(command); let mut chars = command.chars(); chars.next().is_some_and(|ch| ch.is_ascii_alphabetic()) && chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-') } fn validate_paste_text(text: &str) -> Result<(), String> { if text.len() > MAX_EDIT_BYTES { Err("Paste exceeds 4 MiB".into()) } else { validate_text(text) } } fn validate_text(text: &str) -> Result<(), String> { if let Some(ch) = text .chars() .find(|ch| ch.is_control() && !matches!(ch, '\t' | '\n' | '\r')) { Err(format!("Unsupported control character U+{:04X}", ch as u32)) } else { Ok(()) } } fn normalize_newlines(text: &str) -> Cow<'_, str> { if !text.contains('\r') { return Cow::Borrowed(text); } let mut normalized = String::with_capacity(text.len()); let mut chars = text.chars().peekable(); while let Some(ch) = chars.next() { if ch == '\r' { if chars.peek() == Some(&'\n') { chars.next(); } normalized.push('\n'); } else { normalized.push(ch); } } Cow::Owned(normalized) } fn split_lines(text: &str) -> Vec { normalize_newlines(text) .split('\n') .map(str::to_owned) .collect() } fn grapheme_count(line: &str) -> usize { line.graphemes(true).count() } fn grapheme_byte(line: &str, column: usize) -> usize { line.grapheme_indices(true) .nth(column) .map_or(line.len(), |(byte, _)| byte) } fn column_after_byte(line: &str, byte: usize) -> usize { line.grapheme_indices(true) .take_while(|(index, _)| *index < byte) .count() } fn path_identity(path: &Path) -> io::Result { let absolute = if path.is_absolute() { path.to_owned() } else { std::env::current_dir()?.join(path) }; // Resolve the existing prefix too, so new files under aliased directories deduplicate. for ancestor in absolute.ancestors() { match fs::canonicalize(ancestor) { Ok(prefix) => { let mut identity = prefix; for component in absolute .strip_prefix(ancestor) .expect("path ancestor") .components() { match component { Component::ParentDir => { identity.pop(); } Component::CurDir => {} component => identity.push(component.as_os_str()), } } return Ok(identity); } Err(error) if error.kind() == io::ErrorKind::NotFound => {} Err(error) => return Err(error), } } Err(io::Error::new( io::ErrorKind::NotFound, "No existing path ancestor", )) } fn target_metadata(path: &Path) -> io::Result> { match fs::symlink_metadata(path) { Ok(metadata) if metadata.file_type().is_symlink() => Err(io::Error::new( io::ErrorKind::InvalidInput, "Refusing to save through a symbolic link", )), Ok(metadata) if !metadata.is_file() => Err(io::Error::new( io::ErrorKind::InvalidInput, "Save target is not a regular file", )), Ok(metadata) => Ok(Some(metadata)), Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None), Err(error) => Err(error), } } fn atomic_save(path: &Path, lines: &[String]) -> io::Result<()> { let metadata = target_metadata(path)?; let parent = path .parent() .filter(|parent| !parent.as_os_str().is_empty()) .unwrap_or(Path::new(".")); let (temporary, mut file) = loop { let id = TEMP_ID.fetch_add(1, Ordering::Relaxed); let temporary = parent.join(format!(".vamp-{}-{id}.tmp", std::process::id())); let mut options = OpenOptions::new(); options.write(true).create_new(true); #[cfg(unix)] options.mode(0o600); match options.open(&temporary) { Ok(file) => break (temporary, file), Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue, Err(error) => return Err(error), } }; let result = (|| { for (index, line) in lines.iter().enumerate() { if index > 0 { file.write_all(b"\n")?; } file.write_all(line.as_bytes())?; } if let Some(metadata) = metadata { file.set_permissions(metadata.permissions())?; } file.sync_all()?; drop(file); // Recheck immediately before rename: never knowingly replace a symlink. target_metadata(path)?; fs::rename(&temporary, path)?; #[cfg(unix)] fs::File::open(parent)?.sync_all()?; Ok(()) })(); if result.is_err() { let _ = fs::remove_file(&temporary); } result } #[cfg(test)] mod tests { use super::*; struct Directory(PathBuf); impl Directory { fn new() -> Self { let id = TEMP_ID.fetch_add(1, Ordering::Relaxed); let path = std::env::temp_dir().join(format!("vamp-editor-test-{}-{id}", std::process::id())); fs::create_dir(&path).unwrap(); Self(path) } fn path(&self, name: &str) -> PathBuf { self.0.join(name) } } impl Drop for Directory { fn drop(&mut self) { let _ = fs::remove_dir_all(&self.0); } } fn editor() -> Editor { let mut editor = Editor::open(None, vec!["text".into()]).unwrap(); editor.set_clipboard_mode("separate").unwrap(); editor } fn type_text(editor: &mut Editor, text: &str) { for ch in text.chars() { editor.handle(if ch == '\n' { Input::Enter } else { Input::Char(ch) }); } } fn project_editor(directory: &Directory) -> Editor { let mut editor = editor(); editor.project_root = directory.0.clone(); editor } #[cfg(unix)] mod system_clipboard { use super::*; use std::os::unix::fs::PermissionsExt; struct Fixture(Directory); impl Fixture { fn new() -> Self { let fixture = Self(Directory::new()); for name in ["state", "reads", "writes"] { fs::write(fixture.0.path(name), b"").unwrap(); } let quoted = |name: &str| { format!( "'{}'", fixture .0 .path(name) .display() .to_string() .replace('\'', "'\\''") ) }; let reader = format!( "#!/bin/sh\nprintf . >> {}\n[ ! -f {} ] || exit 23\nexec /bin/cat {}\n", quoted("reads"), quoted("fail-read"), quoted("state"), ); let writer = format!( "#!/bin/sh\nprintf . >> {}\n[ ! -f {} ] || exit 23\n/bin/cat > {} || exit 24\nexec /bin/mv {} {}\n", quoted("writes"), quoted("fail-write"), quoted("next"), quoted("next"), quoted("state"), ); for (name, script) in [("reader", reader), ("writer", writer)] { let path = fixture.0.path(name); fs::write(&path, script).unwrap(); fs::set_permissions(path, fs::Permissions::from_mode(0o700)).unwrap(); } fixture } fn editor(&self) -> Editor { let mut editor = Editor::open(None, vec!["text".into()]).unwrap(); editor.set_system_clipboard(SystemClipboard::with_commands( self.0.path("reader"), self.0.path("writer"), )); editor } fn bytes(&self, name: &str) -> Vec { fs::read(self.0.path(name)).unwrap() } fn external(&self, text: impl AsRef<[u8]>) { fs::write(self.0.path("state"), text).unwrap(); } fn fail(&self, name: &str, enabled: bool) { let path = self.0.path(name); if enabled { fs::write(path, b"").unwrap(); } else { fs::remove_file(path).unwrap(); } } } // Includes every replay-owned state, not the intentionally changed error message. fn state(editor: &Editor) -> String { format!( "{:?}", ( ( &editor.active.lines, &editor.active.targets, editor.active.primary, editor.active.mode, &editor.active.search_query, editor.active.search_match, &editor.active.undo, &editor.active.redo, ), ( &editor.active.insert_start, &editor.active.pending_edit, &editor.active.visual_motions, &editor.clipboard, &editor.owned_system_clipboard, &editor.last_edit, editor.clipboard_mode, ), ), ) } #[test] fn default_system_native_copy_cut_change_exports_exact_selected_text() { for key in ['y', 'd', 'x', 'c'] { let fixture = Fixture::new(); let mut editor = fixture.editor(); assert_eq!(editor.clipboard_mode(), ClipboardMode::System); editor.clipboard = vec!["internal".into()]; editor .apply(vec![Effect::Replace("a界e\u{301}z\n👩‍🚀tail\n".into())]) .unwrap(); set_ranges( &mut editor, &[(Cursor { row: 0, column: 1 }, Cursor { row: 1, column: 1 })], 0, ); let undo = editor.active.undo.len(); editor.handle(Input::Char(key)); assert_eq!(fixture.bytes("state"), "界e\u{301}z\n👩‍🚀".as_bytes()); assert_eq!(fixture.bytes("writes").len(), 1); assert_eq!(fixture.bytes("reads").len(), 0); assert_eq!(editor.clipboard, ["internal"]); match key { 'y' => { assert_eq!(editor.context().text, "a界e\u{301}z\n👩‍🚀tail\n"); assert_eq!(editor.active.undo.len(), undo); } 'c' => { assert_eq!(editor.mode(), Mode::Insert); assert!(editor.active.pending_edit.is_some()); type_text(&mut editor, "Q"); editor.handle(Input::Escape); assert_eq!(editor.context().text, "aQtail\n"); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "a界e\u{301}z\n👩‍🚀tail\n"); } _ => { assert_eq!(editor.context().text, "atail\n"); assert_eq!(editor.active.undo.len(), undo + 1); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "a界e\u{301}z\n👩‍🚀tail\n"); } } } } #[test] fn system_paste_before_after_normalizes_valid_external_text_and_records_it() { for (key, action, expected) in [ ('P', None, "界\t\n:q!\nab"), ('p', None, "a界\t\n:q!\nb"), ('z', Some("core.paste_system_before"), "界\t\n:q!\nab"), ('z', Some("core.paste_system_after"), "a界\t\n:q!\nb"), ] { let fixture = Fixture::new(); let mut editor = fixture.editor(); if let Some(action) = action { editor.set_clipboard_mode("separate").unwrap(); editor.bind("normal", "z", Some(action.into())).unwrap(); } editor.apply(vec![Effect::Replace("ab".into())]).unwrap(); editor.clipboard = vec!["stale".into()]; fixture.external("界\t\r\n:q!\r"); editor.handle(Input::Char(key)); assert_eq!(editor.context().text, expected); assert_eq!(editor.mode(), Mode::Normal); assert_eq!(editor.clipboard, ["stale"]); assert_eq!(fixture.bytes("state"), "界\t\r\n:q!\r".as_bytes()); assert_eq!(fixture.bytes("writes").len(), 0); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "ab"); assert!(!editor.should_quit()); } } #[test] fn own_fragments_pair_external_multiline_broadcasts_and_empty_never_uses_internal() { let fixture = Fixture::new(); let mut editor = fixture.editor(); editor .apply(vec![Effect::Replace("ab\nxyz".into())]) .unwrap(); set_carets(&mut editor, &[(0, 0), (1, 0)], 1); type_text(&mut editor, "vey"); assert_eq!(fixture.bytes("state"), b"ab\nxyz"); assert_eq!( editor.owned_system_clipboard.as_ref().unwrap().1, ["ab", "xyz"] ); editor .apply(vec![Effect::Replace("1\n2\n3".into())]) .unwrap(); set_carets(&mut editor, &[(0, 0), (1, 0), (2, 0)], 2); let before = state(&editor); editor.handle(Input::Char('P')); assert_eq!(state(&editor), before); assert!(editor.message().contains("fragment count")); editor.apply(vec![Effect::Replace("1\n2".into())]).unwrap(); set_carets(&mut editor, &[(0, 0), (1, 0)], 1); editor.handle(Input::Char('P')); assert_eq!(editor.context().text, "ab1\nxyz2"); editor.apply(vec![Effect::Replace("1\n2".into())]).unwrap(); set_carets(&mut editor, &[(0, 0), (1, 0)], 1); fixture.external("left\nright\n"); editor.handle(Input::Char('P')); assert_eq!(editor.context().text, "left\nright\n1\nleft\nright\n2"); assert!(editor.owned_system_clipboard.is_none()); editor.apply(vec![Effect::Replace("1\n2".into())]).unwrap(); set_carets(&mut editor, &[(0, 0), (1, 0)], 1); fixture.external("ab\nxyz"); editor.handle(Input::Char('P')); assert_eq!(editor.context().text, "ab\nxyz1\nab\nxyz2"); editor.clipboard = vec!["stale".into()]; fixture.external(""); let before = state(&editor); editor.handle(Input::Char('p')); assert_eq!(state(&editor), before); assert!(editor.message().contains("empty")); } #[test] fn separate_and_explicit_system_actions_are_independent_and_mode_checked() { let fixture = Fixture::new(); let mut editor = fixture.editor(); editor.set_clipboard_mode("SePaRaTe").unwrap(); assert_eq!(editor.clipboard_mode(), ClipboardMode::Separate); for mode in ["", "invalid", " system "] { assert!(editor.set_clipboard_mode(mode).is_err()); assert_eq!(editor.clipboard_mode(), ClipboardMode::Separate); } fixture.external("outside"); fixture.fail("fail-read", true); fixture.fail("fail-write", true); editor .apply(vec![Effect::Replace("inside".into())]) .unwrap(); type_text(&mut editor, "veyP"); assert_eq!(editor.context().text, "insideinside"); assert_eq!(editor.clipboard, ["inside"]); assert_eq!(fixture.bytes("reads").len(), 0); assert_eq!(fixture.bytes("writes").len(), 0); fixture.fail("fail-read", false); fixture.fail("fail-write", false); for action in ["core.paste_system_before", "core.paste_system_after"] { assert!(editor.bind("visual", "z", Some(action.into())).is_err()); assert!(editor.bind("insert", "z", Some(action.into())).is_err()); editor.bind("normal", "z", Some(action.into())).unwrap(); } assert!( editor .bind("normal", "q", Some("core.copy_system".into())) .is_err() ); assert!( editor .bind("insert", "q", Some("core.copy_system".into())) .is_err() ); editor .bind("visual", "q", Some("core.copy_system".into())) .unwrap(); editor .apply(vec![Effect::Replace("system".into())]) .unwrap(); editor.handle(Input::Home); type_text(&mut editor, "veq"); assert_eq!(fixture.bytes("state"), b"system"); assert_eq!(editor.clipboard, ["inside"]); editor.apply(vec![Effect::Replace("!".into())]).unwrap(); editor.handle(Input::Char('z')); assert_eq!(editor.context().text, "!system"); assert_eq!(editor.clipboard, ["inside"]); editor.handle(Input::Char('P')); assert_eq!(editor.context().text, "!systeinsidem"); assert_eq!(fixture.bytes("state"), b"system"); editor.set_clipboard_mode("SYSTEM").unwrap(); editor.handle(Input::Ctrl('p')); let entries = &editor.palette().unwrap().entries; for name in [ "core.copy_system", "core.paste_system_before", "core.paste_system_after", ] { let entry = entries.iter().find(|entry| entry.name == name).unwrap(); assert_eq!(entry.enabled, name != "core.copy_system"); } } #[test] fn provider_and_invalid_text_errors_preserve_all_editing_state() { for key in ['y', 'd', 'x', 'c'] { let fixture = Fixture::new(); let mut editor = fixture.editor(); editor .apply(vec![Effect::Replace("ab\ncd".into())]) .unwrap(); type_text(&mut editor, "iX"); editor.handle(Input::Escape); editor.handle(Input::Char('u')); editor.clipboard = vec!["internal".into()]; editor.store_clipboard(vec!["owned".into()], true).unwrap(); set_carets(&mut editor, &[(0, 0), (1, 0)], 1); type_text(&mut editor, "ve"); fixture.fail("fail-write", true); let before = state(&editor); editor.handle(Input::Char(key)); assert_eq!(state(&editor), before); assert_eq!(fixture.bytes("state"), b"owned"); assert!(!editor.message().is_empty()); } for invalid in [ b"valid\nbad\0".to_vec(), b"\x1b[31m".to_vec(), b"\xff".to_vec(), vec![b'x'; MAX_EDIT_BYTES + 1], ] { let fixture = Fixture::new(); let mut editor = fixture.editor(); editor .apply(vec![Effect::Replace("original".into())]) .unwrap(); editor.store_clipboard(vec!["owned".into()], true).unwrap(); editor.clipboard = vec!["internal".into()]; fixture.external(invalid); let before = state(&editor); editor.handle(Input::Char('p')); assert_eq!(state(&editor), before); assert!(!editor.message().is_empty()); } let fixture = Fixture::new(); let mut editor = fixture.editor(); type_text(&mut editor, "iX"); editor.handle(Input::Escape); fixture.fail("fail-read", true); let before = state(&editor); editor.handle(Input::Char('P')); assert_eq!(state(&editor), before); assert!(!editor.message().is_empty()); } #[test] fn separate_cut_change_and_copy_never_invoke_system_transport() { for key in ['y', 'd', 'x', 'c'] { let fixture = Fixture::new(); let mut editor = fixture.editor(); editor.set_clipboard_mode("separate").unwrap(); fixture.external("outside"); fixture.fail("fail-read", true); fixture.fail("fail-write", true); editor .apply(vec![Effect::Replace("inside".into())]) .unwrap(); type_text(&mut editor, "ve"); editor.handle(Input::Char(key)); assert_eq!(editor.clipboard, ["inside"]); assert_eq!( editor.context().text, if key == 'y' { "inside" } else { "" } ); assert_eq!( editor.mode(), if key == 'c' { Mode::Insert } else { Mode::Normal } ); assert_eq!(fixture.bytes("state"), b"outside"); assert_eq!(fixture.bytes("reads").len(), 0); assert_eq!(fixture.bytes("writes").len(), 0); } } #[test] fn empty_visual_copy_and_change_preserve_both_registers() { let fixture = Fixture::new(); let mut editor = fixture.editor(); editor.clipboard = vec!["internal".into()]; editor.store_clipboard(vec!["owned".into()], true).unwrap(); fixture.fail("fail-write", true); for key in ['y', 'd', 'c'] { editor.active.mode = Mode::Visual; editor.handle(Input::Char(key)); assert_eq!(editor.clipboard, ["internal"]); assert_eq!(fixture.bytes("state"), b"owned"); assert_eq!(fixture.bytes("writes").len(), 1); assert_eq!(editor.context().text, ""); if key == 'c' { assert_eq!(editor.mode(), Mode::Insert); type_text(&mut editor, "X"); editor.handle(Input::Escape); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, ""); } } } #[test] fn registers_and_mode_are_session_wide_across_buffers() { let fixture = Fixture::new(); let mut editor = fixture.editor(); editor.set_clipboard_mode("separate").unwrap(); editor .apply(vec![Effect::Replace("inside".into())]) .unwrap(); type_text(&mut editor, "vey"); editor.set_clipboard_mode("system").unwrap(); editor .apply(vec![Effect::Replace("ab\nxyz".into())]) .unwrap(); set_carets(&mut editor, &[(0, 0), (1, 0)], 1); type_text(&mut editor, "vey"); let other = fixture.0.path("other"); fs::write(&other, "1\n2").unwrap(); editor.execute(&format!("edit {}", other.display())); assert_eq!(editor.clipboard_mode(), ClipboardMode::System); set_carets(&mut editor, &[(0, 0), (1, 0)], 1); editor.handle(Input::Char('P')); assert_eq!(editor.context().text, "ab1\nxyz2"); editor.set_clipboard_mode("separate").unwrap(); editor.execute("buffer 1"); assert_eq!(editor.clipboard_mode(), ClipboardMode::Separate); set_carets(&mut editor, &[(0, 0)], 0); editor.handle(Input::Char('P')); assert_eq!(editor.context().text, "insideab\nxyz"); assert_eq!(fixture.bytes("state"), b"ab\nxyz"); } #[test] fn dot_paste_uses_captured_fragments_without_reading_or_publishing() { let fixture = Fixture::new(); let mut editor = fixture.editor(); editor .apply(vec![Effect::Replace("ab\nxyz".into())]) .unwrap(); set_carets(&mut editor, &[(0, 0), (1, 0)], 1); type_text(&mut editor, "vey"); editor.apply(vec![Effect::Replace("1\n2".into())]).unwrap(); set_carets(&mut editor, &[(0, 0), (1, 0)], 1); editor.handle(Input::Char('P')); fixture.external("external"); fixture.fail("fail-read", true); fixture.fail("fail-write", true); editor .apply(vec![Effect::Replace("3\n4\n5".into())]) .unwrap(); set_carets(&mut editor, &[(0, 0), (1, 0), (2, 0)], 2); let before = state(&editor); editor.handle(Input::Char('.')); assert_eq!(state(&editor), before); editor.set_clipboard_mode("separate").unwrap(); editor.clipboard = vec!["other".into()]; set_carets(&mut editor, &[(0, 0), (1, 0)], 1); editor.handle(Input::Char('.')); assert_eq!(editor.context().text, "ab3\nxyz4\n5"); assert_eq!(editor.clipboard, ["other"]); assert_eq!(fixture.bytes("reads").len(), 1); assert_eq!(fixture.bytes("writes").len(), 1); assert_eq!(fixture.bytes("state"), b"external"); } #[test] fn dot_selection_stages_publication_and_rolls_back_step_or_provider_failures() { let fixture = Fixture::new(); let mut editor = fixture.editor(); editor .apply(vec![Effect::Replace("ab\ncd".into())]) .unwrap(); set_carets(&mut editor, &[(0, 0), (1, 0)], 1); type_text(&mut editor, "vc"); editor.handle(Input::Backspace); type_text(&mut editor, "X"); editor.handle(Input::Escape); let original_clipboard = fixture.bytes("state"); editor .apply(vec![Effect::Replace("aa\nbb\ncc".into())]) .unwrap(); set_carets(&mut editor, &[(0, 0), (1, 0), (2, 0)], 2); editor.handle(Input::Char('v')); let before = state(&editor); editor.handle(Input::Char('.')); assert_eq!(state(&editor), before); assert_eq!(fixture.bytes("writes").len(), 1); assert_eq!(fixture.bytes("state"), original_clipboard); assert!(editor.message().contains("count")); editor .apply(vec![Effect::Replace("aa\nbb".into())]) .unwrap(); set_carets(&mut editor, &[(0, 0), (1, 0)], 1); editor.handle(Input::Char('v')); editor.active.search_query = "aa".into(); editor.active.search_match = Some(SearchMatch { start: Cursor::default(), end_column: 2, }); fixture.fail("fail-write", true); let before = state(&editor); editor.handle(Input::Char('.')); assert_eq!(state(&editor), before); assert_eq!(fixture.bytes("state"), original_clipboard); assert_eq!(fixture.bytes("writes").len(), 2); fixture.fail("fail-write", false); let undo = editor.active.undo.len(); editor.handle(Input::Char('.')); assert_eq!(editor.context().text, "XaXb"); assert_eq!(fixture.bytes("state"), b"a\nb"); assert_eq!(fixture.bytes("writes").len(), 3); assert_eq!(editor.active.undo.len(), undo + 1); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "aa\nbb"); assert_eq!(fixture.bytes("state"), b"a\nb"); } #[test] fn dot_unequal_spans_refuses_before_publication_and_preserves_recipe() { let fixture = Fixture::new(); let mut editor = fixture.editor(); editor .apply(vec![Effect::Replace("ab\nxyz".into())]) .unwrap(); set_ranges( &mut editor, &[ (Cursor::default(), Cursor { row: 0, column: 2 }), (Cursor { row: 1, column: 0 }, Cursor { row: 1, column: 3 }), ], 1, ); editor.handle(Input::Char('d')); editor .apply(vec![Effect::Replace("12\n345".into())]) .unwrap(); set_carets(&mut editor, &[(0, 0)], 0); let before = state(&editor); editor.handle(Input::Char('.')); assert_eq!(state(&editor), before); assert_eq!(fixture.bytes("writes").len(), 1); set_carets(&mut editor, &[(0, 0), (1, 0)], 1); editor.handle(Input::Char('.')); assert_eq!(editor.context().text, "\n"); assert_eq!(fixture.bytes("state"), b"12\n345"); assert_eq!(fixture.bytes("writes").len(), 2); } #[test] fn dot_cut_follows_current_policy_without_copying_plain_deletes_or_plugin_edits() { let fixture = Fixture::new(); let mut editor = fixture.editor(); editor.set_clipboard_mode("separate").unwrap(); editor.apply(vec![Effect::Replace("first".into())]).unwrap(); type_text(&mut editor, "ved"); assert_eq!(editor.clipboard, ["first"]); assert_eq!(fixture.bytes("writes").len(), 0); editor.set_clipboard_mode("system").unwrap(); editor .apply(vec![Effect::Replace("second".into())]) .unwrap(); set_carets(&mut editor, &[(0, 0)], 0); editor.handle(Input::Char('.')); assert_eq!(editor.context().text, ""); assert_eq!(fixture.bytes("state"), b"second"); assert_eq!(editor.clipboard, ["first"]); editor.set_clipboard_mode("separate").unwrap(); editor.apply(vec![Effect::Replace("third".into())]).unwrap(); set_carets(&mut editor, &[(0, 0)], 0); editor.handle(Input::Char('.')); assert_eq!(editor.clipboard, ["third"]); assert_eq!(fixture.bytes("state"), b"second"); editor.set_clipboard_mode("system").unwrap(); editor.apply(vec![Effect::Replace("plain".into())]).unwrap(); set_carets(&mut editor, &[(0, 0)], 0); type_text(&mut editor, "x."); type_text(&mut editor, "iX"); editor.handle(Input::Backspace); editor.handle(Input::Delete); editor.handle(Input::Escape); editor.handle(Input::Char('.')); assert_eq!(fixture.bytes("writes").len(), 1); assert_eq!(fixture.bytes("reads").len(), 0); } } #[test] fn floating_default_tree_starts_filtering_then_escape_navigates_before_close() { let directory = Directory::new(); fs::create_dir(directory.path("dir")).unwrap(); fs::write(directory.path("dir/first.txt"), "first").unwrap(); fs::write(directory.path("dir/second.txt"), "second").unwrap(); let mut editor = project_editor(&directory); type_text(&mut editor, " osecond.txt"); let picker = editor.file_picker().unwrap(); assert_eq!(picker.view, FileView::Tree); assert!(picker.focused && picker.searching); assert_eq!(picker.query, "second.txt"); assert_eq!( picker.entries().nth(picker.selected).unwrap().path, Path::new("dir/second.txt") ); editor.handle(Input::Escape); let picker = editor.file_picker().unwrap(); assert!(picker.focused && !picker.searching); assert_eq!(picker.query, "second.txt"); assert_eq!( picker.entries().nth(picker.selected).unwrap().path, Path::new("dir/second.txt") ); editor.handle(Input::Char('i')); assert!(editor.file_picker().unwrap().searching); assert_eq!(editor.file_picker().unwrap().query, "second.txt"); editor.handle(Input::Escape); editor.handle(Input::Char('h')); editor.handle(Input::Char('h')); let picker = editor.file_picker().unwrap(); assert_eq!(picker.entries().len(), 1); assert_eq!(picker.entries().next().unwrap().path, Path::new("dir")); assert!(!picker.entries().next().unwrap().expanded); assert_eq!(picker.query, "second.txt"); editor.handle(Input::Char('l')); assert!( editor .file_picker() .unwrap() .entries() .next() .unwrap() .expanded ); editor.handle(Input::Escape); assert!(editor.file_picker().is_none()); assert_eq!(editor.lines(), &[String::new()]); editor.set_file_picker_view("list").unwrap(); type_text(&mut editor, " osecond.txt"); assert_eq!(editor.file_picker().unwrap().view, FileView::List); editor.handle(Input::Enter); assert_eq!(editor.lines(), &["second"]); assert!(editor.file_picker().is_none()); } #[test] fn filter_escape_resume_and_clear_do_not_edit_underlying_buffers_or_history() { let directory = Directory::new(); fs::create_dir_all(directory.path("src/deep")).unwrap(); fs::write(directory.path("src/deep/needle.txt"), "needle").unwrap(); fs::write(directory.path("src/other.txt"), "other").unwrap(); let selected_path = |editor: &Editor| { let picker = editor.file_picker().unwrap(); picker .entries() .nth(picker.selected) .unwrap() .path .to_owned() }; for presentation in [FilePresentation::Floating, FilePresentation::Docked] { for mode in [ Mode::Normal, Mode::Insert, Mode::Command, Mode::Search, Mode::Visual, ] { let mut editor = project_editor(&directory); editor .apply(vec![Effect::Replace("alpha\nbeta".into())]) .unwrap(); editor .apply(vec![Effect::Replace("changed".into())]) .unwrap(); editor.act(Action::Undo); set_carets(&mut editor, &[(0, 1), (1, 2)], 1); editor.begin_insert(); editor.active.mode = mode; editor.command_line = "original prompt".into(); editor.open_file_picker(presentation); if !editor.file_picker().unwrap().searching { editor.handle(Input::Char('i')); } editor.count = Some(7); let snapshot = |state: &Snapshot| (state.lines.clone(), state.targets.clone(), state.primary); let lines = editor.lines().to_owned(); let targets = editor.active.targets.clone(); let primary = editor.active.primary; let undo: Vec<_> = editor.active.undo.iter().map(&snapshot).collect(); let redo: Vec<_> = editor.active.redo.iter().map(&snapshot).collect(); let insert = editor.active.insert_start.as_ref().map(snapshot); let buffer = editor.buffer_id(); let buffers = editor.buffer_count(); type_text(&mut editor, "needle"); let expected = PathBuf::from("src/deep/needle.txt"); assert_eq!(selected_path(&editor), expected); editor.handle(Input::Escape); assert!(!editor.file_picker().unwrap().searching); assert_eq!(editor.file_picker().unwrap().query, "needle"); assert_eq!(selected_path(&editor), expected); editor.handle(Input::Char('i')); assert!(editor.file_picker().unwrap().searching); assert_eq!(editor.file_picker().unwrap().query, "needle"); assert_eq!(selected_path(&editor), expected); editor.handle(Input::Ctrl('U')); assert!(editor.file_picker().unwrap().searching); assert!(editor.file_picker().unwrap().query.is_empty()); assert_eq!(selected_path(&editor), expected); type_text(&mut editor, "needle"); editor.handle(Input::Escape); editor.handle(Input::Ctrl('u')); assert!(!editor.file_picker().unwrap().searching); assert!(editor.file_picker().unwrap().query.is_empty()); assert_eq!(selected_path(&editor), expected); editor.handle(Input::Ctrl('u')); assert_eq!(selected_path(&editor), expected); editor.handle(Input::Char('i')); type_text(&mut editor, "missing"); editor.handle(Input::Ctrl('j')); editor.handle(Input::Ctrl('k')); editor.handle(Input::Escape); assert_eq!(editor.file_picker().unwrap().entries().len(), 0); editor.handle(Input::Ctrl('u')); assert_eq!(selected_path(&editor), expected); assert!(editor.file_picker().unwrap().focused); assert!(!editor.file_picker().unwrap().searching); assert_eq!(editor.mode(), mode); assert_eq!(editor.lines(), lines); assert_eq!(editor.active.targets, targets); assert_eq!(editor.active.primary, primary); assert_eq!(editor.command_line, "original prompt"); assert_eq!(editor.count(), Some(7)); assert_eq!(editor.buffer_id(), buffer); assert_eq!(editor.buffer_count(), buffers); assert_eq!( editor.active.undo.iter().map(&snapshot).collect::>(), undo ); assert_eq!( editor.active.redo.iter().map(&snapshot).collect::>(), redo ); assert_eq!(editor.active.insert_start.as_ref().map(snapshot), insert); editor.handle(Input::Escape); match presentation { FilePresentation::Floating => assert!(editor.file_picker().is_none()), FilePresentation::Docked => { assert!(!editor.file_picker().unwrap().focused); editor.open_file_picker(presentation); assert!(!editor.file_picker().unwrap().searching); assert_eq!(selected_path(&editor), expected); } } } } } #[test] fn filtered_folder_keys_and_directory_accept_work_in_both_presentations() { let directory = Directory::new(); fs::create_dir_all(directory.path("src/deep")).unwrap(); fs::write(directory.path("src/deep/needle.txt"), "needle").unwrap(); fs::write(directory.path("src/other.txt"), "other").unwrap(); for presentation in [FilePresentation::Floating, FilePresentation::Docked] { let mut editor = project_editor(&directory); editor.open_file_picker(presentation); if !editor.file_picker().unwrap().searching { editor.handle(Input::Char('i')); } type_text(&mut editor, "needle"); editor.handle(Input::Ctrl('h')); let picker = editor.file_picker().unwrap(); assert!(picker.searching); assert_eq!(picker.query, "needle"); assert_eq!( picker.entries().nth(picker.selected).unwrap().path, Path::new("src/deep") ); assert!(picker.entries().nth(picker.selected).unwrap().expanded); editor.handle(Input::Ctrl('l')); let picker = editor.file_picker().unwrap(); assert_eq!( picker.entries().nth(picker.selected).unwrap().path, Path::new("src/deep/needle.txt") ); editor.handle(Input::Escape); editor.handle(Input::Char('h')); editor.handle(Input::Char('h')); let picker = editor.file_picker().unwrap(); assert!(!picker.searching); assert_eq!(picker.query, "needle"); assert_eq!(picker.entries().len(), 2); assert_eq!( picker.entries().nth(picker.selected).unwrap().path, Path::new("src/deep") ); assert!(!picker.entries().nth(picker.selected).unwrap().expanded); editor.handle(Input::Char('l')); assert!( editor .file_picker() .unwrap() .entries() .nth(1) .unwrap() .expanded ); editor.handle(Input::Char('l')); editor.handle(Input::Char('h')); editor.handle(Input::Enter); assert!( !editor .file_picker() .unwrap() .entries() .nth(1) .unwrap() .expanded ); editor.handle(Input::Enter); assert!( editor .file_picker() .unwrap() .entries() .nth(1) .unwrap() .expanded ); editor.handle(Input::Char('i')); editor.handle(Input::Enter); let picker = editor.file_picker().unwrap(); assert!(!picker.searching && picker.focused); assert_eq!(picker.query, "needle"); assert_eq!( picker.entries().nth(picker.selected).unwrap().path, Path::new("src/deep") ); assert!(!picker.entries().nth(picker.selected).unwrap().expanded); assert_eq!(editor.context().text, ""); editor.handle(Input::Ctrl('r')); editor.handle(Input::Ctrl('t')); editor.handle(Input::Ctrl('t')); let picker = editor.file_picker().unwrap(); assert!(!picker.searching && picker.focused); assert_eq!(picker.query, "needle"); assert!(!picker.entries().nth(picker.selected).unwrap().expanded); if presentation == FilePresentation::Docked { editor.handle(Input::Escape); editor.open_file_picker(presentation); assert!(!editor.file_picker().unwrap().searching); assert_eq!(editor.file_picker().unwrap().query, "needle"); } editor.open_file_picker(FilePresentation::Floating); assert!(editor.file_picker().unwrap().searching); assert_eq!(editor.file_picker().unwrap().query, "needle"); } } #[test] fn filter_clear_respects_navigation_bindings_and_does_not_clear_palette_or_idle_dock() { let directory = Directory::new(); for name in ["a.txt", "b.txt"] { fs::write(directory.path(name), "text").unwrap(); } for view in [FileView::List, FileView::Tree] { let mut editor = project_editor(&directory); editor.open_file_picker(FilePresentation::Docked); editor.file_picker.as_mut().unwrap().set_view(view); if view == FileView::Tree { editor.handle(Input::Char('i')); } type_text(&mut editor, "txt"); editor .bind("selection", "ctrl+u", Some("core.down".into())) .unwrap(); editor.handle(Input::Ctrl('u')); assert_eq!(editor.file_picker().unwrap().query, "txt"); assert_eq!(editor.file_picker().unwrap().selected, 1); editor.bind("selection", "ctrl+u", None).unwrap(); editor.handle(Input::Ctrl('u')); assert_eq!(editor.file_picker().unwrap().query, "txt"); editor.selection_bindings.remove(&Input::Ctrl('u')); if view == FileView::Tree { editor.bind("tree", "ctrl+u", None).unwrap(); editor.handle(Input::Ctrl('u')); assert_eq!(editor.file_picker().unwrap().query, "txt"); editor.tree_bindings.remove(&Input::Ctrl('u')); } editor.handle(Input::Ctrl('u')); assert!(editor.file_picker().unwrap().query.is_empty()); assert_eq!(editor.file_picker().unwrap().selected, 1); assert_eq!( editor.file_picker().unwrap().searching, view == FileView::Tree ); type_text(&mut editor, "txt"); editor.handle(Input::Char('\t')); editor.handle(Input::Ctrl('u')); assert_eq!(editor.file_picker().unwrap().query, "txt"); editor.handle(Input::Ctrl('p')); type_text(&mut editor, "core"); editor.handle(Input::Ctrl('u')); assert_eq!(editor.palette().unwrap().query, "core"); assert_eq!(editor.file_picker().unwrap().query, "txt"); } } #[test] fn selection_navigation_wraps_without_touching_any_underlying_mode() { let directory = Directory::new(); for name in ["a.txt", "b.txt", "c.txt"] { fs::write(directory.path(name), "file text").unwrap(); } for surface in [ None, Some(FilePresentation::Floating), Some(FilePresentation::Docked), ] { for mode in [ Mode::Normal, Mode::Insert, Mode::Command, Mode::Search, Mode::Visual, ] { let mut editor = project_editor(&directory); editor .apply(vec![Effect::Replace("alpha\nbeta".into())]) .unwrap(); set_carets(&mut editor, &[(0, 1), (1, 2)], 1); editor.active.mode = mode; editor.command_line = "original prompt".into(); if let Some(presentation) = surface { editor.open_file_picker(presentation); editor .file_picker .as_mut() .unwrap() .set_view(FileView::List); type_text(&mut editor, "txt"); } else { editor.handle(Input::Ctrl('p')); type_text(&mut editor, "core"); } let list = |editor: &Editor| { if let Some(picker) = editor.file_picker() { ( picker.query.clone(), picker.selected, picker.entries().len(), ) } else { let palette = editor.palette().unwrap(); ( palette.query.clone(), palette.selected, palette.entries.len(), ) } }; let (query, _, len) = list(&editor); assert!(len > 1); let snapshot = |state: &Snapshot| (state.lines.clone(), state.targets.clone(), state.primary); let text = editor.context().text; let targets = editor.active.targets.clone(); let primary = editor.active.primary; let undo: Vec<_> = editor.active.undo.iter().map(&snapshot).collect(); let redo: Vec<_> = editor.active.redo.iter().map(&snapshot).collect(); let insert = editor.active.insert_start.as_ref().map(snapshot); editor.count = Some(7); for (input, selected) in [ (Input::Ctrl('K'), len - 1), (Input::Ctrl('J'), 0), (Input::Ctrl('j'), 1), (Input::Ctrl('k'), 0), (Input::Up, len - 1), (Input::Down, 0), (Input::Ctrl('n'), 1), ] { assert_eq!(editor.handle(input), None); assert_eq!(list(&editor), (query.clone(), selected, len)); } assert_eq!(editor.mode(), mode); assert_eq!(editor.context().text, text); assert_eq!(editor.active.targets, targets); assert_eq!(editor.active.primary, primary); assert_eq!(editor.command_line, "original prompt"); assert_eq!(editor.count(), Some(7)); assert_eq!( editor.active.undo.iter().map(&snapshot).collect::>(), undo ); assert_eq!( editor.active.redo.iter().map(&snapshot).collect::>(), redo ); assert_eq!(editor.active.insert_start.as_ref().map(snapshot), insert); type_text(&mut editor, "missing"); assert_eq!(list(&editor).2, 0); for input in [Input::Ctrl('j'), Input::Ctrl('k'), Input::Enter] { assert_eq!(editor.handle(input), None); assert_eq!(list(&editor).1, 0); assert_eq!(editor.context().text, text); assert_eq!(editor.mode(), mode); } } } } #[test] fn selection_remaps_and_disables_are_shared_and_invalid_edits_are_atomic() { let directory = Directory::new(); for name in ["a.txt", "b.txt", "c.txt"] { fs::write(directory.path(name), "file text").unwrap(); } for surface in [ None, Some(FilePresentation::Floating), Some(FilePresentation::Docked), ] { let mut editor = project_editor(&directory); assert_eq!( editor.selection_navigation_hint(), "Ctrl+j/Ctrl+n/Down next · Ctrl+k/Up previous" ); type_text(&mut editor, "3 b"); let pending = editor.leader_pending().unwrap().to_owned(); editor .bind("selection", "x", Some("core.down".into())) .unwrap(); editor .bind("selection", "z", Some("core.up".into())) .unwrap(); editor.bind("selection", "ctrl+j", None).unwrap(); editor.bind("selection", "!", None).unwrap(); assert_eq!(editor.count(), Some(3)); assert_eq!(editor.leader_pending(), Some(pending.as_str())); assert_eq!( editor.selection_navigation_hint(), "Ctrl+n/Down/x next · Ctrl+k/Up/z previous" ); let hint = editor.selection_navigation_hint().to_owned(); for (mode, key, target) in [ ("selection", "x", Some("core.delete")), ("selection", "x", Some("core.palette")), ("selection", "x", Some("w")), ("selection", "leader x", Some("core.down")), ("selection", "LEADER x", None), ("selection", "x z", Some("core.up")), ("selection", "alt+x", Some("core.up")), ("invalid", "x", Some("core.up")), ] { assert!(editor.bind(mode, key, target.map(str::to_owned)).is_err()); assert_eq!(editor.selection_navigation_hint(), hint); assert_eq!(editor.count(), Some(3)); assert_eq!(editor.leader_pending(), Some(pending.as_str())); } editor.cancel_leader(); // Editing bindings cannot disable or remap list bindings. editor .bind("normal", "x", Some("core.insert".into())) .unwrap(); editor.bind("normal", "ctrl+k", None).unwrap(); if let Some(presentation) = surface { editor.open_file_picker(presentation); editor .file_picker .as_mut() .unwrap() .set_view(FileView::List); } else { editor.handle(Input::Ctrl('p')); } let selected = |editor: &Editor| { if let Some(picker) = editor.file_picker() { picker.selected } else { editor.palette().unwrap().selected } }; for (input, expected) in [ (Input::Char('x'), 1), (Input::Ctrl('j'), 1), (Input::Char('!'), 1), (Input::Char('z'), 0), (Input::Char('x'), 1), (Input::Ctrl('k'), 0), ] { editor.handle(input); assert_eq!(selected(&editor), expected); assert_eq!(editor.mode(), Mode::Normal); } // Updating the map does not rebuild/reset the selected list. editor.handle(Input::Char('x')); editor .bind("selection", "ctrl+p", Some("core.down".into())) .unwrap(); assert_eq!(selected(&editor), 1); editor.handle(Input::Ctrl('p')); assert_eq!(selected(&editor), 2); editor.bind("selection", "backspace", None).unwrap(); type_text(&mut editor, "a"); editor.handle(Input::Backspace); let query = if let Some(picker) = editor.file_picker() { &picker.query } else { &editor.palette().unwrap().query }; assert_eq!(query, "a"); for key in ["down", "ctrl+n", "x", "ctrl+p"] { editor.bind("selection", key, None).unwrap(); } assert_eq!( editor.selection_navigation_hint(), "unbound next · Ctrl+k/Up/z previous" ); editor.handle(Input::Escape); editor.handle(Input::Char('x')); assert_eq!(editor.mode(), Mode::Insert); } } #[test] fn ctrl_j_is_distinct_from_enter_and_preserves_editor_defaults() { assert_eq!(parse_key("CTRL+J").unwrap(), Input::Ctrl('j')); for (key, input) in [ ("ctrl+m", Input::Enter), ("ctrl+h", Input::Ctrl('h')), ("ctrl+i", Input::Char('\t')), ] { assert_eq!(parse_key(key).unwrap(), input); } let mut editor = editor(); editor .bind("normal", "ctrl+j", Some("core.insert".into())) .unwrap(); editor.handle(Input::Enter); assert_eq!(editor.mode(), Mode::Normal); editor.handle(Input::Ctrl('j')); assert_eq!(editor.mode(), Mode::Insert); editor.bind("insert", "enter", None).unwrap(); type_text(&mut editor, "one"); editor.handle(Input::Enter); editor.handle(Input::Ctrl('j')); type_text(&mut editor, "two"); assert_eq!(editor.context().text, "one\ntwo"); editor.handle(Input::Escape); editor.register(plugin("fixture", &["greet"])).unwrap(); type_text(&mut editor, ":greet arguments"); let invocation = editor.handle(Input::Ctrl('j')).unwrap(); assert_eq!(invocation.command, "greet"); assert_eq!(invocation.args, "arguments"); assert_eq!(editor.mode(), Mode::Normal); type_text(&mut editor, "/two"); editor.handle(Input::Ctrl('j')); assert_eq!(editor.mode(), Mode::Normal); assert_eq!(editor.active.search_query, "two"); assert_eq!(editor.cursor().row, 1); editor .bind("visual", "ctrl+j", Some("core.up".into())) .unwrap(); editor.handle(Input::Char('v')); editor.handle(Input::Ctrl('j')); assert_eq!(editor.mode(), Mode::Visual); assert_eq!(editor.cursor().row, 0); } #[test] fn ctrl_j_navigates_lists_while_enter_accepts() { let directory = Directory::new(); fs::write(directory.path("a.txt"), "first").unwrap(); fs::write(directory.path("b.txt"), "second").unwrap(); for presentation in [FilePresentation::Floating, FilePresentation::Docked] { let mut editor = project_editor(&directory); editor.open_file_picker(presentation); editor.handle(Input::Ctrl('j')); assert_eq!(editor.file_picker().unwrap().selected, 1); assert_eq!(editor.context().text, ""); editor.handle(Input::Enter); assert_eq!(editor.context().text, "second"); assert_eq!(editor.path(), Some(directory.path("b.txt").as_path())); } let mut editor = editor(); editor .register(plugin("fixture", &["greet_one", "greet_two"])) .unwrap(); editor.handle(Input::Ctrl('p')); type_text(&mut editor, "greet"); editor.handle(Input::Ctrl('j')); assert_eq!(editor.palette().unwrap().selected, 1); assert_eq!(editor.mode(), Mode::Normal); assert_eq!(editor.command_line, ""); editor.handle(Input::Enter); assert!(editor.palette().is_none()); assert_eq!(editor.mode(), Mode::Command); assert_eq!(editor.command_line, "greet_two "); } #[test] fn file_actions_default_leaders_palette_and_side_configuration() { let directory = Directory::new(); fs::write(directory.path("file.txt"), "abcdef").unwrap(); for visual in [false, true] { let mut editor = project_editor(&directory); editor .apply(vec![Effect::Replace("abcdef".into())]) .unwrap(); if visual { type_text(&mut editor, "vl"); } let mode = editor.mode(); let selections = editor.selections(); let carets = editor.carets(); for (suffix, presentation) in [ ("3 o", FilePresentation::Floating), ("2 e", FilePresentation::Docked), ] { type_text(&mut editor, suffix); let picker = editor.file_picker().unwrap(); assert_eq!(picker.presentation, presentation); assert!(picker.focused); assert_eq!(editor.mode(), mode); assert_eq!(editor.selections(), selections); assert_eq!(editor.carets(), carets); assert_eq!(editor.count(), None); assert_eq!(editor.leader_pending(), None); editor.handle(Input::Ctrl('w')); assert!(editor.file_picker().is_none()); } editor.handle(Input::Ctrl('p')); for (name, binding) in [ ("core.file_picker", "Space o"), ("core.file_explorer", "Space e"), ] { let entry = palette_entry(&editor, name); assert!(entry.enabled); assert_eq!(entry.bindings, vec![binding]); } editor.handle(Input::Escape); assert_eq!(editor.file_explorer_side(), DockSide::Right); for (name, side) in [ ("LEFT", DockSide::Left), ("rIgHt", DockSide::Right), ("top", DockSide::Top), ("BOTTOM", DockSide::Bottom), ] { editor.set_file_explorer_side(name).unwrap(); assert_eq!(editor.file_explorer_side(), side); } assert!(editor.set_file_explorer_side("middle").is_err()); assert_eq!(editor.file_explorer_side(), DockSide::Bottom); assert!( editor .bind("insert", "ctrl+o", Some("core.file_picker".into())) .is_err() ); assert!( editor .bind("command", "ctrl+e", Some("core.file_explorer".into())) .is_err() ); } } #[test] fn picker_focus_literal_input_navigation_and_empty_selection() { let directory = Directory::new(); for name in ["a space 界[e\u{301}].txt", "b.txt", "c.txt"] { fs::write(directory.path(name), "text").unwrap(); } let mut editor = project_editor(&directory); editor.set_file_picker_view("list").unwrap(); type_text(&mut editor, " o"); editor.handle(Input::Ctrl('K')); assert_eq!(editor.file_picker().unwrap().selected, 2); editor.handle(Input::Ctrl('N')); assert_eq!(editor.file_picker().unwrap().selected, 0); editor.handle(Input::Up); editor.handle(Input::Down); assert_eq!(editor.file_picker().unwrap().selected, 0); type_text(&mut editor, "界 SPACE [E\u{301}]"); assert_eq!(editor.file_picker().unwrap().entries().len(), 1); assert_eq!(editor.context().text, ""); assert_eq!(editor.count(), None); assert_eq!(editor.leader_pending(), None); let query = editor.file_picker().unwrap().query.clone(); editor.handle(Input::Paste("ignored 5 b n".into())); assert_eq!(editor.file_picker().unwrap().query, query); editor.handle(Input::Enter); assert!(editor.file_picker().is_none()); assert_eq!( editor.path(), Some(directory.path("a space 界[e\u{301}].txt").as_path()) ); assert_eq!(editor.context().text, "text"); type_text(&mut editor, " o"); type_text(&mut editor, "e\u{301}👩‍💻"); editor.handle(Input::Backspace); assert_eq!(editor.file_picker().unwrap().query, "e\u{301}"); editor.handle(Input::Backspace); assert_eq!(editor.file_picker().unwrap().query, ""); editor.handle(Input::Backspace); type_text(&mut editor, "missing 123"); let id = editor.buffer_id(); editor.handle(Input::Up); editor.handle(Input::Down); editor.handle(Input::Enter); assert_eq!(editor.buffer_id(), id); assert_eq!(editor.file_picker().unwrap().selected, 0); assert!(editor.file_picker().unwrap().focused); editor.handle(Input::Escape); let empty = Directory::new(); editor.project_root = empty.0.clone(); type_text(&mut editor, " e"); editor.handle(Input::Up); editor.handle(Input::Down); editor.handle(Input::Ctrl('j')); editor.handle(Input::Ctrl('k')); editor.handle(Input::Enter); assert_eq!(editor.buffer_id(), id); assert_eq!(editor.file_picker().unwrap().entries().len(), 0); } #[test] fn picker_conversion_reuses_query_and_scan_refreshes_only_on_request() { let directory = Directory::new(); fs::write(directory.path("keep.txt"), "text").unwrap(); let mut editor = project_editor(&directory); type_text(&mut editor, " eikeep"); editor.handle(Input::Char('\t')); assert!(!editor.file_picker().unwrap().focused); fs::write(directory.path("keep new.txt"), "new").unwrap(); type_text(&mut editor, " o"); let picker = editor.file_picker().unwrap(); assert_eq!(picker.presentation, FilePresentation::Floating); assert_eq!(picker.query, "keep"); assert_eq!(picker.entries().len(), 1); editor.open_file_picker(FilePresentation::Docked); assert_eq!(editor.file_picker().unwrap().query, "keep"); editor.handle(Input::Ctrl('R')); assert_eq!(editor.file_picker().unwrap().entries().len(), 2); assert_eq!(editor.file_picker().unwrap().query, "keep"); editor.handle(Input::Char('\t')); assert!(!editor.file_picker().unwrap().focused); type_text(&mut editor, " e"); assert!(editor.file_picker().unwrap().focused); assert_eq!(editor.file_picker().unwrap().query, "keep"); fs::remove_file(directory.path("keep new.txt")).unwrap(); editor.handle(Input::Ctrl('r')); assert_eq!(editor.file_picker().unwrap().entries().len(), 1); editor.handle(Input::Ctrl('w')); assert!(editor.file_picker().is_none()); type_text(&mut editor, " o"); assert_eq!(editor.file_picker().unwrap().query, ""); editor.handle(Input::Escape); assert!(editor.file_picker().unwrap().focused); editor.handle(Input::Escape); assert!(editor.file_picker().is_none()); } #[test] fn picker_open_reuses_loaded_dirty_buffer_and_dock_survives_lifecycle() { let directory = Directory::new(); let a = directory.path("a space 界.txt"); let b = directory.path("b.txt"); fs::write(&a, "original").unwrap(); fs::write(&b, "other").unwrap(); let mut editor = project_editor(&directory); editor.execute(&format!("edit {}", a.display())); editor.apply(vec![Effect::Replace("dirty".into())]).unwrap(); type_text(&mut editor, "vl"); let selections = editor.selections(); let id = editor.buffer_id(); editor.execute(&format!("edit {}", b.display())); fs::write(&a, b"\xff").unwrap(); type_text(&mut editor, " eia space 界"); editor.handle(Input::Enter); assert_eq!(editor.buffer_id(), id); assert_eq!(editor.buffer_count(), 3); assert_eq!(editor.context().text, "dirty"); assert!(editor.dirty()); assert_eq!(editor.mode(), Mode::Visual); assert_eq!(editor.selections(), selections); assert!(!editor.file_picker().unwrap().focused); assert_eq!(editor.file_picker().unwrap().query, "a space 界"); editor.execute("bnext"); assert!(editor.file_picker().is_some()); editor.execute("bdelete"); assert!(editor.file_picker().is_some()); editor.execute("buffer 1"); assert_eq!( editor.file_picker().unwrap().presentation, FilePresentation::Docked ); assert_eq!(editor.file_picker().unwrap().query, "a space 界"); } #[test] fn failed_picker_selection_preserves_visual_state_and_insert_group() { let directory = Directory::new(); let bad = directory.path("bad.txt"); fs::write(&bad, b"\xff").unwrap(); let mut editor = project_editor(&directory); editor .apply(vec![Effect::Replace("abcdef".into())]) .unwrap(); type_text(&mut editor, "llvhh"); let selections = editor.selections(); let carets = editor.carets(); let undo = editor.active.undo.len(); type_text(&mut editor, " obad"); editor.handle(Input::Enter); assert_eq!(editor.mode(), Mode::Visual); assert_eq!(editor.selections(), selections); assert_eq!(editor.carets(), carets); assert_eq!(editor.context().text, "abcdef"); assert_eq!(editor.active.undo.len(), undo); assert_eq!(editor.next_buffer_id, 2); assert_eq!(editor.buffer_count(), 1); assert!(editor.file_picker().unwrap().focused); assert!( editor .file_picker() .unwrap() .error .as_ref() .unwrap() .contains("Open failed") ); editor.handle(Input::Down); assert!(editor.file_picker().unwrap().error.is_none()); editor.handle(Input::Enter); editor.handle(Input::Backspace); assert!(editor.file_picker().unwrap().error.is_none()); editor.handle(Input::Escape); editor.handle(Input::Escape); editor.handle(Input::Escape); editor.handle(Input::Char('i')); type_text(&mut editor, "X"); editor.open_file_picker(FilePresentation::Docked); editor.handle(Input::Enter); assert_eq!(editor.mode(), Mode::Insert); assert!(editor.active.insert_start.is_some()); assert!(editor.file_picker().unwrap().error.is_some()); fs::remove_file(&bad).unwrap(); editor.handle(Input::Enter); assert_eq!(editor.buffer_id(), 1); assert_eq!(editor.mode(), Mode::Insert); assert!(editor.file_picker().unwrap().error.is_some()); fs::create_dir(&bad).unwrap(); editor.handle(Input::Enter); assert_eq!(editor.buffer_count(), 1); assert!(editor.file_picker().unwrap().focused); editor.handle(Input::Escape); assert!(!editor.file_picker().unwrap().focused); type_text(&mut editor, "Y"); editor.handle(Input::Escape); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "abcdef"); assert!(editor.file_picker().is_some()); } #[test] fn picker_palette_and_external_commands_are_mutually_exclusive() { let directory = Directory::new(); fs::write(directory.path("file.txt"), "text").unwrap(); let mut editor = project_editor(&directory); editor.handle(Input::Ctrl('p')); editor.open_file_picker(FilePresentation::Docked); assert!(editor.palette().is_none()); editor.handle(Input::Ctrl('p')); assert!(editor.palette().is_some()); assert!(!editor.file_picker().unwrap().focused); type_text(&mut editor, "file picker"); editor.handle(Input::Enter); assert!(editor.palette().is_none()); assert_eq!( editor.file_picker().unwrap().presentation, FilePresentation::Floating ); assert!(editor.file_picker().unwrap().focused); editor.handle(Input::Ctrl('p')); assert!(editor.file_picker().is_none()); assert!(editor.palette().is_some()); editor.handle(Input::Escape); type_text(&mut editor, " o"); editor.execute("buffers"); assert!(editor.file_picker().is_none()); type_text(&mut editor, " eifile"); editor.execute("buffers"); assert!(!editor.file_picker().unwrap().focused); assert_eq!(editor.file_picker().unwrap().query, "file"); type_text(&mut editor, " e"); editor.handle(Input::Ctrl('w')); assert!(editor.file_picker().is_none()); } #[test] fn unfocused_dock_keeps_editor_counts_leaders_palette_and_literal_input() { let directory = Directory::new(); fs::write(directory.path("file.txt"), "text").unwrap(); let mut editor = project_editor(&directory); editor .apply(vec![Effect::Replace("a\nb\nc\nd".into())]) .unwrap(); type_text(&mut editor, " eifile"); editor.handle(Input::Char('\t')); type_text(&mut editor, "2j"); assert_eq!(editor.cursor().row, 2); assert_eq!(editor.file_picker().unwrap().query, "file"); type_text(&mut editor, "3 b"); assert_eq!(editor.leader_pending(), Some("Space b")); assert_eq!(editor.count(), Some(3)); editor.handle(Input::Escape); type_text(&mut editor, "i o e123"); editor.handle(Input::Paste(" literal".into())); assert!(editor.context().text.contains(" o e123 literal")); assert_eq!(editor.file_picker().unwrap().query, "file"); editor.handle(Input::Escape); type_text(&mut editor, ": o e123"); assert_eq!(editor.mode(), Mode::Command); assert_eq!(editor.command_line(), " o e123"); editor.handle(Input::Escape); type_text(&mut editor, "/ o e123"); assert_eq!(editor.mode(), Mode::Search); assert_eq!(editor.command_line(), " o e123"); editor.handle(Input::Escape); type_text(&mut editor, " "); assert!(editor.palette().is_some()); assert!(!editor.file_picker().unwrap().focused); } #[test] fn canceling_picker_does_not_commit_insert_and_root_failure_is_not_empty_success() { let directory = Directory::new(); let mut editor = project_editor(&directory); type_text(&mut editor, "iX"); editor.open_file_picker(FilePresentation::Floating); editor.handle(Input::Paste("ignored".into())); editor.handle(Input::Escape); assert!(editor.file_picker().unwrap().focused); editor.handle(Input::Escape); assert_eq!(editor.mode(), Mode::Insert); assert!(editor.active.insert_start.is_some()); assert_eq!(editor.context().text, "X"); type_text(&mut editor, "Y"); editor.handle(Input::Escape); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, ""); fs::remove_dir_all(&directory.0).unwrap(); type_text(&mut editor, " o"); assert!(editor.file_picker().is_none()); assert!(editor.message().contains("File scan failed")); assert_eq!(editor.buffer_count(), 1); } #[cfg(unix)] #[test] fn picker_opens_raw_paths_even_when_sanitized_display_labels_collide() { let directory = Directory::new(); let names = [PathBuf::from("same\n.txt"), PathBuf::from("same\t.txt")]; fs::write(directory.0.join(&names[0]), "first").unwrap(); fs::write(directory.0.join(&names[1]), "second").unwrap(); let mut editor = project_editor(&directory); for (name, text) in names.iter().zip(["first", "second"]) { type_text(&mut editor, " osame"); let picker = editor.file_picker.as_mut().unwrap(); assert_eq!(picker.entries().len(), 2); assert_eq!( picker.entries().next().unwrap().label, picker.entries().last().unwrap().label, ); assert!( picker .entries() .all(|entry| !entry.label.chars().any(char::is_control)) ); let selected = picker .entries() .position(|entry| entry.path == name.as_path()) .unwrap(); picker.selected = selected; editor.handle(Input::Enter); assert_eq!(editor.path(), Some(directory.0.join(name).as_path())); assert_eq!(editor.context().text, text); } assert_eq!(editor.buffer_count(), 3); } #[test] fn file_view_preferences_are_independent_atomic_and_applied_to_current_presentation() { let directory = Directory::new(); fs::create_dir(directory.path("folder")).unwrap(); fs::write(directory.path("file.txt"), "text").unwrap(); let mut editor = project_editor(&directory); editor.open_file_picker(FilePresentation::Docked); assert_eq!(editor.file_picker().unwrap().view, FileView::Tree); editor.set_file_picker_view("TREE").unwrap(); assert_eq!(editor.file_picker().unwrap().view, FileView::Tree); editor.set_file_explorer_view("LiSt").unwrap(); assert_eq!(editor.file_picker().unwrap().view, FileView::List); for invalid in ["", "flat", " tree "] { assert!(editor.set_file_picker_view(invalid).is_err()); assert!(editor.set_file_explorer_view(invalid).is_err()); assert_eq!(editor.file_picker_view(), FileView::Tree); assert_eq!(editor.file_explorer_view(), FileView::List); assert_eq!(editor.file_picker().unwrap().view, FileView::List); } editor.handle(Input::Ctrl('t')); assert_eq!(editor.file_picker().unwrap().view, FileView::Tree); assert_eq!(editor.file_explorer_view(), FileView::List); editor.handle(Input::Char('\t')); editor.open_file_picker(FilePresentation::Docked); assert_eq!(editor.file_picker().unwrap().view, FileView::List); editor.open_file_picker(FilePresentation::Floating); assert_eq!(editor.file_picker().unwrap().view, FileView::Tree); assert_eq!( editor.file_picker().unwrap().presentation, FilePresentation::Floating ); editor.set_file_explorer_view("tree").unwrap(); editor.set_file_picker_view("list").unwrap(); assert_eq!(editor.file_picker().unwrap().view, FileView::List); assert_eq!(editor.file_picker().unwrap().entries().len(), 1); } #[test] fn tree_hjkl_navigates_normal_and_control_hjkl_navigates_filter_without_changing_text() { let directory = Directory::new(); fs::create_dir(directory.path("dir")).unwrap(); fs::write(directory.path("dir/hjkl-a.txt"), "a").unwrap(); fs::write(directory.path("dir/hjkl-b.txt"), "b").unwrap(); for presentation in [FilePresentation::Floating, FilePresentation::Docked] { let mut editor = project_editor(&directory); editor.set_file_picker_view("tree").unwrap(); editor.open_file_picker(presentation); if presentation == FilePresentation::Floating { editor.handle(Input::Escape); } let selected = |editor: &Editor| { let picker = editor.file_picker().unwrap(); picker .entries() .nth(picker.selected) .unwrap() .path .to_owned() }; editor.handle(Input::Char('l')); assert!( editor .file_picker() .unwrap() .entries() .next() .unwrap() .expanded ); editor.handle(Input::Char('l')); assert_eq!(selected(&editor), Path::new("dir/hjkl-a.txt")); editor.handle(Input::Char('j')); assert_eq!(selected(&editor), Path::new("dir/hjkl-b.txt")); editor.handle(Input::Char('k')); assert_eq!(selected(&editor), Path::new("dir/hjkl-a.txt")); editor.handle(Input::Char('h')); assert_eq!(selected(&editor), Path::new("dir")); assert!( editor .file_picker() .unwrap() .entries() .next() .unwrap() .expanded ); editor.handle(Input::Char('h')); assert!( !editor .file_picker() .unwrap() .entries() .next() .unwrap() .expanded ); editor.handle(Input::Char('i')); type_text(&mut editor, "hjkl"); assert_eq!(editor.file_picker().unwrap().query, "hjkl"); assert_eq!(selected(&editor), Path::new("dir/hjkl-a.txt")); editor.handle(Input::Ctrl('j')); assert_eq!(selected(&editor), Path::new("dir/hjkl-b.txt")); editor.handle(Input::Ctrl('k')); assert_eq!(selected(&editor), Path::new("dir/hjkl-a.txt")); editor.handle(Input::Ctrl('h')); assert_eq!(selected(&editor), Path::new("dir")); editor.handle(Input::Ctrl('l')); assert_eq!(selected(&editor), Path::new("dir/hjkl-a.txt")); assert_eq!(editor.file_picker().unwrap().query, "hjkl"); editor.handle(Input::Escape); assert!(editor.file_picker().unwrap().focused); assert_eq!(selected(&editor), Path::new("dir/hjkl-a.txt")); assert_eq!(editor.file_picker().unwrap().query, "hjkl"); editor.handle(Input::Ctrl('u')); assert_eq!(selected(&editor), Path::new("dir/hjkl-a.txt")); editor.handle(Input::Char('h')); editor.handle(Input::Char('h')); assert!( !editor .file_picker() .unwrap() .entries() .next() .unwrap() .expanded ); editor.bind("tree", "j", Some("core.up".into())).unwrap(); editor.bind("tree", "k", None).unwrap(); editor.handle(Input::Char('l')); editor.handle(Input::Char('j')); assert_eq!(selected(&editor), Path::new("dir/hjkl-b.txt")); editor.handle(Input::Char('k')); assert_eq!(selected(&editor), Path::new("dir/hjkl-b.txt")); editor.handle(Input::Char('i')); type_text(&mut editor, "jk"); assert_eq!(editor.file_picker().unwrap().query, "jk"); assert_eq!(editor.lines(), &[String::new()]); assert_eq!(editor.mode(), Mode::Normal); assert_eq!(editor.cursor(), Cursor::default()); } } #[test] fn tree_browse_and_search_have_separate_input_and_two_stage_escape() { let directory = Directory::new(); fs::create_dir_all(directory.path("folder/deep")).unwrap(); fs::write(directory.path("folder/deep/needle.txt"), "text").unwrap(); fs::write(directory.path("folder/other.txt"), "other").unwrap(); for presentation in [FilePresentation::Floating, FilePresentation::Docked] { for mode in [ Mode::Normal, Mode::Visual, Mode::Insert, Mode::Command, Mode::Search, ] { let mut editor = project_editor(&directory); editor .apply(vec![Effect::Replace("alpha\nbeta".into())]) .unwrap(); set_carets(&mut editor, &[(0, 1), (1, 2)], 1); editor.active.mode = mode; editor.command_line = "underlying".into(); let targets = editor.active.targets.clone(); let text = editor.context().text; let undo = editor.active.undo.len(); editor.set_file_picker_view("tree").unwrap(); editor.open_file_picker(presentation); if presentation == FilePresentation::Floating { editor.handle(Input::Escape); } type_text(&mut editor, "123 b xyz"); editor.handle(Input::Paste("ignored".into())); editor.handle(Input::Backspace); assert_eq!(editor.file_picker().unwrap().query, ""); assert_eq!(editor.count(), None); assert_eq!(editor.leader_pending(), None); editor.handle(Input::Enter); assert_eq!(editor.buffer_count(), 1); assert!( editor .file_picker() .unwrap() .entries() .next() .unwrap() .expanded ); editor.handle(Input::Ctrl('l')); assert_eq!( editor.file_picker().unwrap().entries().nth(1).unwrap().path, Path::new("folder/deep") ); assert_eq!(editor.file_picker().unwrap().selected, 1); editor.handle(Input::Ctrl('h')); assert_eq!(editor.file_picker().unwrap().selected, 0); type_text(&mut editor, "ideep/"); assert!(editor.file_picker().unwrap().searching); assert_eq!(editor.file_picker().unwrap().query, "deep/"); editor.handle(Input::Ctrl('H')); assert_eq!(editor.file_picker().unwrap().query, "deep/"); editor.handle(Input::Backspace); assert_eq!(editor.file_picker().unwrap().query, "deep"); type_text(&mut editor, "👩‍💻"); editor.handle(Input::Backspace); assert_eq!(editor.file_picker().unwrap().query, "deep"); let before = editor .file_picker() .unwrap() .entries() .map(|row| row.path.to_owned()) .collect::>(); let selected_before = editor.file_picker().unwrap().selected; editor.handle(Input::Escape); let picker = editor.file_picker().unwrap(); assert!(picker.focused); assert!(!picker.searching); assert_eq!(picker.query, "deep"); assert_eq!(picker.selected, selected_before); assert_eq!( picker .entries() .map(|row| row.path.to_owned()) .collect::>(), before ); editor.handle(Input::Escape); match presentation { FilePresentation::Floating => assert!(editor.file_picker().is_none()), FilePresentation::Docked => assert!(!editor.file_picker().unwrap().focused), } assert_eq!(editor.mode(), mode); assert_eq!(editor.command_line, "underlying"); assert_eq!(editor.active.targets, targets); assert_eq!(editor.context().text, text); assert_eq!(editor.active.undo.len(), undo); } } } #[test] fn tree_binding_precedence_is_modal_and_navigation_preferences_are_atomic() { let directory = Directory::new(); fs::create_dir(directory.path("folder")).unwrap(); fs::write(directory.path("folder/a.txt"), "a").unwrap(); fs::write(directory.path("folder/b.txt"), "b").unwrap(); fs::write(directory.path("z.txt"), "z").unwrap(); let mut editor = project_editor(&directory); editor .bind("selection", "x", Some("core.down".into())) .unwrap(); editor.bind("tree", "x", Some("core.right".into())).unwrap(); editor .bind("tree", "ctrl+j", Some("core.left".into())) .unwrap(); editor.bind("tree", "ctrl+k", None).unwrap(); type_text(&mut editor, "3 b"); let pending = editor.leader_pending().unwrap().to_owned(); let hint = editor.tree_navigation_hint.clone(); for (key, target) in [ ("x", Some("core.delete")), ("x", Some("core.palette")), ("x", Some("w")), ("leader x", Some("core.right")), ("x y", Some("core.left")), ("alt+x", None), ] { assert!(editor.bind("tree", key, target.map(str::to_owned)).is_err()); assert_eq!(editor.tree_navigation_hint, hint); assert_eq!(editor.count(), Some(3)); assert_eq!(editor.leader_pending(), Some(pending.as_str())); } editor.open_file_picker(FilePresentation::Docked); editor.handle(Input::Char('x')); assert_eq!(editor.file_picker().unwrap().selected, 0); assert!( editor .file_picker() .unwrap() .entries() .next() .unwrap() .expanded ); editor.handle(Input::Char('x')); assert_eq!(editor.file_picker().unwrap().selected, 1); editor.handle(Input::Ctrl('k')); assert_eq!(editor.file_picker().unwrap().selected, 1); editor.handle(Input::Ctrl('j')); assert_eq!(editor.file_picker().unwrap().selected, 0); editor.handle(Input::Char('i')); editor.handle(Input::Char('x')); assert_eq!(editor.file_picker().unwrap().selected, 1); assert_eq!(editor.file_picker().unwrap().query, ""); editor.handle(Input::Ctrl('j')); assert_eq!(editor.file_picker().unwrap().selected, 0); editor.handle(Input::Ctrl('h')); assert_eq!(editor.file_picker().unwrap().selected, 0); editor.handle(Input::Ctrl('t')); assert_eq!(editor.file_picker().unwrap().view, FileView::List); assert_eq!( editor.file_navigation_hint(), editor.selection_navigation_hint() ); editor.handle(Input::Char('x')); assert_eq!(editor.file_picker().unwrap().selected, 1); editor.handle(Input::Ctrl('w')); editor.handle(Input::Char('x')); assert_eq!(editor.mode(), Mode::Normal); assert_eq!(editor.context().text, ""); editor.handle(Input::Ctrl('p')); let selected = editor.palette().unwrap().selected; editor.handle(Input::Char('x')); assert_eq!(editor.palette().unwrap().selected, selected + 1); editor.handle(Input::Ctrl('h')); assert_eq!(editor.palette().unwrap().query, ""); } #[test] fn ctrl_h_is_not_backspace_unless_explicitly_bound() { assert_eq!(parse_key("CTRL+H").unwrap(), Input::Ctrl('h')); assert_eq!(parse_key("backspace").unwrap(), Input::Backspace); for mode in [ Mode::Normal, Mode::Visual, Mode::Insert, Mode::Command, Mode::Search, ] { let mut editor = editor(); editor.apply(vec![Effect::Replace("abc".into())]).unwrap(); set_carets(&mut editor, &[(0, 2)], 0); editor.active.mode = mode; editor.command_line = "query".into(); let targets = editor.active.targets.clone(); let undo = editor.active.undo.len(); editor.handle(Input::Ctrl('h')); assert_eq!(editor.context().text, "abc"); assert_eq!(editor.active.targets, targets); assert_eq!(editor.active.undo.len(), undo); assert_eq!(editor.command_line, "query"); assert_eq!(editor.mode(), mode); } let mut editor = editor(); type_text(&mut editor, "iabc"); editor.handle(Input::Ctrl('h')); assert_eq!(editor.context().text, "abc"); editor .bind("insert", "ctrl+h", Some("core.backspace".into())) .unwrap(); editor.handle(Input::Ctrl('h')); assert_eq!(editor.context().text, "ab"); editor.bind("insert", "ctrl+h", None).unwrap(); editor.handle(Input::Ctrl('h')); assert_eq!(editor.context().text, "ab"); editor.handle(Input::Backspace); assert_eq!(editor.context().text, "a"); let directory = Directory::new(); fs::write(directory.path("a.txt"), "text").unwrap(); let mut editor = project_editor(&directory); editor.open_file_picker(FilePresentation::Floating); type_text(&mut editor, "a"); editor.handle(Input::Ctrl('h')); assert_eq!(editor.file_picker().unwrap().query, "a"); editor.handle(Input::Backspace); assert_eq!(editor.file_picker().unwrap().query, ""); editor.handle(Input::Ctrl('p')); type_text(&mut editor, "core"); editor.handle(Input::Ctrl('h')); assert_eq!(editor.palette().unwrap().query, "core"); editor.handle(Input::Backspace); assert_eq!(editor.palette().unwrap().query, "cor"); } #[test] fn tree_leaf_search_opens_direct_match_and_directory_search_enters_browse() { let directory = Directory::new(); fs::create_dir_all(directory.path("src/module")).unwrap(); fs::write(directory.path("src/module/needle.txt"), "needle").unwrap(); fs::write(directory.path("src/sibling.txt"), "sibling").unwrap(); for presentation in [FilePresentation::Floating, FilePresentation::Docked] { let mut editor = project_editor(&directory); editor.set_file_picker_view("tree").unwrap(); editor.open_file_picker(presentation); if presentation == FilePresentation::Docked { editor.handle(Input::Char('i')); } type_text(&mut editor, "needle.txt"); let picker = editor.file_picker().unwrap(); assert_eq!( picker.entries().nth(picker.selected).unwrap().path, Path::new("src/module/needle.txt") ); editor.handle(Input::Enter); assert_eq!(editor.context().text, "needle"); assert_eq!( editor.path(), Some(directory.path("src/module/needle.txt").as_path()) ); editor.open_file_picker(presentation); editor.handle(Input::Ctrl('w')); editor.open_file_picker(presentation); if presentation == FilePresentation::Docked { editor.handle(Input::Char('i')); } type_text(&mut editor, "module"); assert!( editor .file_picker() .unwrap() .entries() .nth(editor.file_picker().unwrap().selected) .unwrap() .is_dir ); let id = editor.buffer_id(); editor.handle(Input::Enter); let picker = editor.file_picker().unwrap(); assert_eq!(editor.buffer_id(), id); assert!(!picker.searching); assert_eq!(picker.query, "module"); let selected = picker.entries().nth(picker.selected).unwrap(); assert_eq!(selected.path, Path::new("src/module")); assert!(!selected.expanded); assert!( !picker .entries() .any(|row| row.path == Path::new("src/module/needle.txt")) ); editor.handle(Input::Enter); let picker = editor.file_picker().unwrap(); assert_eq!(picker.query, "module"); assert!(picker.entries().nth(picker.selected).unwrap().expanded); assert!( picker .entries() .any(|row| row.path == Path::new("src/module/needle.txt")) ); assert_eq!(editor.buffer_count(), 2); } } #[cfg(unix)] #[test] fn tree_search_opens_raw_nested_paths_and_deduplicates_dirty_buffers() { let directory = Directory::new(); fs::create_dir_all(directory.path("dir\n/deep")).unwrap(); let a = directory.path("dir\n/deep/same\n.txt"); let b = directory.path("dir\n/deep/same\t.txt"); fs::write(&a, "first").unwrap(); fs::write(&b, "second").unwrap(); for presentation in [FilePresentation::Floating, FilePresentation::Docked] { fs::write(&a, "first").unwrap(); let mut editor = project_editor(&directory); editor.set_file_picker_view("tree").unwrap(); editor.edit_buffer(a.clone(), false).unwrap(); editor .apply(vec![Effect::Replace("dirty first".into())]) .unwrap(); type_text(&mut editor, "vl"); let selections = editor.selections(); let id = editor.buffer_id(); editor.edit_buffer(b.clone(), false).unwrap(); fs::write(&a, b"\xff").unwrap(); editor.open_file_picker(presentation); if presentation == FilePresentation::Docked { editor.handle(Input::Char('i')); } type_text(&mut editor, "same"); let picker = editor.file_picker.as_mut().unwrap(); let labels = picker .entries() .filter(|row| !row.is_dir) .map(|row| row.label) .collect::>(); assert_eq!(labels.len(), 2); assert_eq!(labels[0], labels[1]); assert!( picker .entries() .all(|row| !row.label.chars().any(char::is_control)) ); let selected = picker .entries() .position(|row| picker.root.join(row.path) == a) .unwrap(); picker.selected = selected; editor.handle(Input::Enter); assert_eq!(editor.path(), Some(a.as_path())); assert_eq!(editor.buffer_id(), id); assert_eq!(editor.buffer_count(), 3); assert_eq!(editor.context().text, "dirty first"); assert!(editor.dirty()); assert_eq!(editor.mode(), Mode::Visual); assert_eq!(editor.selections(), selections); match presentation { FilePresentation::Floating => assert!(editor.file_picker().is_none()), FilePresentation::Docked => { assert!(!editor.file_picker().unwrap().focused); assert!(editor.file_picker().unwrap().searching); assert_eq!(editor.file_picker().unwrap().query, "same"); } } } } #[test] fn default_leader_routes_save_switch_list_delete_and_open_palette() { let directory = Directory::new(); let path = directory.path("first"); let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); editor.set_clipboard_mode("separate").unwrap(); type_text(&mut editor, "ihello"); editor.handle(Input::Escape); type_text(&mut editor, " w"); assert_eq!(fs::read_to_string(&path).unwrap(), "hello"); assert!(!editor.dirty()); editor.execute(&format!("edit {}", directory.path("second").display())); type_text(&mut editor, " b"); assert_eq!(editor.leader_pending(), Some("Space b")); editor.handle(Input::Char('n')); assert_eq!(editor.buffer_id(), 1); assert_eq!(editor.leader_pending(), None); type_text(&mut editor, " bp"); assert_eq!(editor.buffer_id(), 2); type_text(&mut editor, " bl"); assert!(editor.message().contains("first")); assert!(editor.message().contains("second")); type_text(&mut editor, " bd"); assert!(!editor.has_buffer(2)); assert_eq!(editor.buffer_id(), 1); for mode in [Mode::Normal, Mode::Visual] { if mode == Mode::Visual { editor.handle(Input::Char('v')); } editor.handle(Input::Char(' ')); assert_eq!(editor.leader_pending(), Some("Space")); editor.handle(Input::Char(' ')); assert!(editor.palette().is_some()); assert_eq!(editor.mode(), mode); assert_eq!(editor.leader_pending(), None); editor.handle(Input::Escape); } } #[test] fn nested_leader_dispatches_plugin_once_with_count_and_multiple_carets() { let mut editor = editor(); editor .apply(vec![Effect::Replace("aa\nbb".into())]) .unwrap(); set_carets(&mut editor, &[(0, 0), (1, 0)], 1); editor.register(plugin("fixture", &["greet"])).unwrap(); editor .bind("normal", "LeAdEr g ctrl+r 7 shift+i", Some("greet".into())) .unwrap(); type_text(&mut editor, "8 g"); editor.handle(Input::Ctrl('R')); assert_eq!(editor.leader_pending(), Some("Space g Ctrl+r")); editor.handle(Input::Char('7')); assert_eq!(editor.count(), Some(8)); assert_eq!(editor.leader_pending(), Some("Space g Ctrl+r 7")); assert_eq!( editor.handle(Input::Char('I')), Some(Invocation { plugin: "fixture".into(), command: "greet".into(), args: String::new(), }) ); assert_eq!(editor.selection_count(), 2); assert_eq!(editor.context().text, "aa\nbb"); assert_eq!(editor.count(), None); assert_eq!(editor.leader_pending(), None); editor.handle(Input::Ctrl('p')); assert_eq!( palette_entry(&editor, "greet").bindings, vec!["Space g Ctrl+r 7 I"] ); } #[test] fn custom_leader_is_symbolic_and_ordinary_trigger_bindings_preempt_by_mode() { let mut editor = editor(); type_text(&mut editor, "3 b"); editor.set_leader("comma").unwrap(); assert_eq!(editor.leader_pending(), None); assert_eq!(editor.count(), None); editor.handle(Input::Ctrl('p')); assert_eq!(palette_entry(&editor, "bnext").bindings, vec![", b n"]); editor .bind("normal", ",", Some("core.command".into())) .unwrap(); assert!(palette_entry(&editor, "bnext").bindings.is_empty()); editor.set_leader(",").unwrap(); editor.handle(Input::Escape); editor.handle(Input::Char(',')); assert_eq!(editor.mode(), Mode::Command); assert_eq!(editor.leader_pending(), None); editor.handle(Input::Escape); editor.handle(Input::Char('v')); type_text(&mut editor, ", "); assert!(editor.palette().is_some()); assert_eq!(editor.mode(), Mode::Visual); editor.handle(Input::Escape); editor.handle(Input::Escape); editor.bind("normal", ",", None).unwrap(); type_text(&mut editor, "4,"); assert_eq!(editor.count(), None); assert_eq!(editor.leader_pending(), None); editor.set_leader("\\").unwrap(); type_text(&mut editor, "\\b"); assert_eq!(editor.leader_pending(), Some("\\ b")); editor.handle(Input::Char('n')); assert_eq!(editor.leader_pending(), None); editor.handle(Input::Ctrl('p')); assert_eq!(palette_entry(&editor, "bnext").bindings, vec!["\\ b n"]); } #[test] fn leader_prefix_conflicts_and_invalid_replacements_are_atomic() { let mut editor = editor(); assert!(editor.bind("normal", "leader b", None).is_err()); editor .bind("normal", "leader z", Some("core.down".into())) .unwrap(); assert!(editor.bind("normal", "leader z n", None).is_err()); assert!( editor .bind("normal", "leader z", Some("missing".into())) .is_err() ); editor .apply(vec![Effect::Replace("a\nb\nc".into())]) .unwrap(); type_text(&mut editor, " z"); assert_eq!(editor.cursor().row, 1); editor.bind("normal", "leader z", None).unwrap(); assert!( editor .bind("normal", "leader z n", Some("core.down".into())) .is_err() ); type_text(&mut editor, "2 z"); assert_eq!(editor.cursor().row, 1); assert_eq!(editor.count(), None); editor.bind("normal", "leader g n", None).unwrap(); assert!(editor.bind("normal", "leader g", None).is_err()); for key in ["leader", "leader ctrl+", "leader b ctrl++r", "leader "] { assert!(editor.bind("normal", key, None).is_err(), "{key}"); } editor .bind("normal", " LEADER\tz ", Some("core.up".into())) .unwrap(); type_text(&mut editor, " z"); assert_eq!(editor.cursor().row, 0); assert!( editor .leader_bindings .get(&Mode::Normal) .unwrap() .contains_key(&vec![Input::Char('b'), Input::Char('n')]) ); } #[test] fn leader_validation_preserves_choice_and_reuses_action_mode_eligibility() { let mut editor = editor(); editor.register(plugin("fixture", &["greet"])).unwrap(); for mode in ["insert", "command", "search", "unknown"] { assert!(editor.bind(mode, "leader z", None).is_err(), "{mode}"); } assert!( editor .bind("visual", "leader z", Some("core.insert".into())) .is_err() ); assert!( editor .bind("visual", "leader z", Some("greet".into())) .is_err() ); editor .bind("visual", "leader z", Some("core.right".into())) .unwrap(); editor.bind("normal", "comma", None).unwrap(); editor.bind("visual", "\\", None).unwrap(); for key in [ "", "esc", "ESC", "0", "9", "i", "ctrl+p", ",", "\\", "leader b", "alt+j", ] { assert!(editor.set_leader(key).is_err(), "{key}"); assert_eq!(editor.leader, Input::Char(' ')); } editor.bind("insert", "界", None).unwrap(); editor.set_leader("界").unwrap(); editor.handle(Input::Char('界')); assert_eq!(editor.leader_pending(), Some("界")); editor.handle(Input::Escape); editor.set_leader("space").unwrap(); editor.bind("normal", "space", None).unwrap(); editor.set_leader("SPACE").unwrap(); assert_eq!(editor.leader, Input::Char(' ')); } #[test] fn leader_escape_unknown_and_paste_cancel_count_without_changing_selections() { for visual in [false, true] { let mut editor = editor(); editor .apply(vec![Effect::Replace("abcd\nabcd".into())]) .unwrap(); set_carets(&mut editor, &[(0, 1), (1, 1)], 1); if visual { editor .bind("visual", "leader b n", Some("core.down".into())) .unwrap(); type_text(&mut editor, "vl"); } let mode = editor.mode(); let carets = editor.carets(); let selections = editor.selections(); for prefix in [" b", "3 b"] { type_text(&mut editor, prefix); assert_eq!(editor.leader_pending(), Some("Space b")); editor.handle(Input::Escape); assert_eq!(editor.mode(), mode); assert_eq!(editor.carets(), carets); assert_eq!(editor.selections(), selections); assert_eq!(editor.count(), None); assert_eq!(editor.leader_pending(), None); } type_text(&mut editor, "4 b"); editor.handle(Input::Char('x')); assert!( editor .message() .contains("Unknown leader sequence: Space b x") ); assert_eq!(editor.context().text, "abcd\nabcd"); assert_eq!(editor.carets(), carets); assert_eq!(editor.selections(), selections); assert_eq!(editor.mode(), mode); assert_eq!(editor.count(), None); assert_eq!(editor.leader_pending(), None); type_text(&mut editor, "5 b"); editor.handle(Input::Paste("n".into())); assert_eq!(editor.context().text, "abcd\nabcd"); assert_eq!(editor.carets(), carets); assert_eq!(editor.count(), None); assert_eq!(editor.leader_pending(), None); editor.handle(Input::Escape); assert_eq!(editor.mode(), Mode::Normal); assert_eq!(editor.selection_count(), if visual { 2 } else { 1 }); } } #[test] fn leader_digit_suffix_uses_pending_count_for_remapped_motion_not_new_count() { let mut editor = editor(); editor .apply(vec![Effect::Replace(["abcd"; 12].join("\n"))]) .unwrap(); set_carets(&mut editor, &[(0, 0), (1, 0)], 1); editor .bind("normal", "leader g 0 2", Some("core.down".into())) .unwrap(); type_text(&mut editor, "3 g0"); assert_eq!(editor.count(), Some(3)); assert_eq!(editor.leader_pending(), Some("Space g 0")); editor.handle(Input::Char('2')); assert_eq!( editor.carets(), vec![Cursor { row: 3, column: 0 }, Cursor { row: 4, column: 0 }] ); assert_eq!(editor.count(), None); assert_eq!(editor.leader_pending(), None); editor .bind("visual", "leader 7", Some("core.right".into())) .unwrap(); editor.handle(Input::Char('v')); type_text(&mut editor, "2 7"); assert_eq!(editor.cursor(), Cursor { row: 4, column: 2 }); assert_eq!(editor.mode(), Mode::Visual); assert_eq!(editor.selection_count(), 2); assert_eq!(editor.count(), None); } #[test] fn leader_keys_are_literal_in_insert_prompts_and_palette_queries() { for (key, ch) in [("space", ' '), ("comma", ',')] { for mode_key in ['i', ':', '/'] { let mut editor = editor(); editor.set_leader(key).unwrap(); editor.handle(Input::Char(mode_key)); let text = format!("{ch}b n{ch}"); type_text(&mut editor, &text); assert_eq!(editor.leader_pending(), None); assert_eq!(editor.count(), None); if mode_key == 'i' { assert_eq!(editor.context().text, text); assert_eq!(editor.mode(), Mode::Insert); } else { assert_eq!(editor.command_line(), text); assert_eq!( editor.mode(), if mode_key == ':' { Mode::Command } else { Mode::Search } ); } let mode = editor.mode(); editor.handle(Input::Ctrl('p')); type_text(&mut editor, "core word next "); assert_eq!(editor.palette().unwrap().query, "core word next "); assert_eq!(editor.leader_pending(), None); editor.handle(Input::Escape); assert_eq!(editor.mode(), mode); } } } #[test] fn leader_routes_retire_with_plugins_and_binding_changes_cancel_pending() { let mut editor = editor(); editor.register(plugin("fixture", &["greet"])).unwrap(); editor.bind("normal", "g", Some("greet".into())).unwrap(); editor .bind("normal", "leader g n", Some("greet".into())) .unwrap(); type_text(&mut editor, "3 g"); editor .bind("normal", "leader g n", Some("core.down".into())) .unwrap(); assert_eq!(editor.leader_pending(), None); assert_eq!(editor.count(), None); type_text(&mut editor, "2 g"); editor.bind("normal", "z", None).unwrap(); assert_eq!(editor.leader_pending(), None); assert_eq!(editor.count(), None); editor .bind("normal", "leader g n", Some("greet".into())) .unwrap(); type_text(&mut editor, "4 g"); editor.disable_plugin("fixture", "retired"); assert_eq!(editor.leader_pending(), None); assert_eq!(editor.count(), None); assert_eq!(editor.handle(Input::Char('g')), None); type_text(&mut editor, " g"); assert_eq!(editor.leader_pending(), None); assert!(editor.message().contains("Unknown leader sequence")); assert!( editor .bind("normal", "leader g n", Some("greet".into())) .is_err() ); editor.handle(Input::Ctrl('p')); assert!( !editor .palette() .unwrap() .entries .iter() .any(|entry| entry.name == "greet") ); } #[test] fn pending_leader_never_survives_external_commands_or_buffer_transitions() { let directory = Directory::new(); let mut editor = editor(); editor.apply(vec![Effect::Replace("abcd".into())]).unwrap(); editor.execute(&format!("edit {}", directory.path("other").display())); editor.set_leader(",").unwrap(); for command in [ "buffer 1", "buffer 1", "bnext", "bprev", "buffers", "buffer 999", ] { type_text(&mut editor, "3,b"); assert_eq!(editor.leader_pending(), Some(", b")); editor.execute(command); assert_eq!(editor.leader_pending(), None); assert_eq!(editor.count(), None); editor.handle(Input::Char(',')); assert_eq!(editor.leader_pending(), Some(",")); editor.handle(Input::Escape); } editor.execute("buffer 1"); type_text(&mut editor, "vl"); let selections = editor.selections(); type_text(&mut editor, "2,"); editor.execute("buffer 2"); editor.execute("buffer 1"); assert_eq!(editor.mode(), Mode::Visual); assert_eq!(editor.selections(), selections); assert_eq!(editor.leader_pending(), None); assert_eq!(editor.count(), None); editor.handle(Input::Char(',')); editor .apply(vec![Effect::Replace("replacement".into())]) .unwrap(); assert_eq!(editor.mode(), Mode::Normal); assert_eq!(editor.leader_pending(), None); editor .bind("normal", "leader i", Some("core.insert".into())) .unwrap(); type_text(&mut editor, ",i"); assert_eq!(editor.mode(), Mode::Insert); assert_eq!(editor.leader_pending(), None); } #[test] fn counted_cursor_motions_apply_to_all_carets_and_clamp_through_short_rows() { let mut editor = editor(); editor .apply(vec![Effect::Replace( "e\u{301}界👩‍🚀z\n\nlong\nfour\naa\nbbbb\ncccc".into(), )]) .unwrap(); set_carets(&mut editor, &[(0, 3), (4, 1)], 1); let undo = editor.active.undo.len(); type_text(&mut editor, "2j"); assert_eq!( editor.carets(), vec![Cursor { row: 2, column: 0 }, Cursor { row: 6, column: 1 }] ); assert_eq!(editor.cursor(), Cursor { row: 6, column: 1 }); type_text(&mut editor, "100k"); assert_eq!(editor.carets(), vec![Cursor::default()]); type_text(&mut editor, "2l"); assert_eq!(editor.cursor(), Cursor { row: 0, column: 2 }); type_text(&mut editor, "9h"); assert_eq!(editor.cursor(), Cursor::default()); type_text(&mut editor, "999999999999999999999999999999999999"); assert_eq!(editor.count(), Some(usize::MAX)); editor.handle(Input::Down); assert_eq!(editor.cursor(), Cursor { row: 6, column: 0 }); assert_eq!(editor.count(), None); assert_eq!(editor.active.undo.len(), undo); assert_eq!( editor.lines(), &["e\u{301}界👩‍🚀z", "", "long", "four", "aa", "bbbb", "cccc"] ); } #[test] fn counted_words_match_repeated_unicode_motions_without_intermediate_selection_merging() { let text = "foo_bar...baz qux\n\n \n界e\u{301}_2 👩‍🚀!! tail\n"; for key in ['w', 'b', 'e', 'W', 'B', 'E'] { for positions in [&[(0, 0), (3, 0)][..], &[(0, 8), (3, 8)][..]] { let mut counted = editor(); counted.apply(vec![Effect::Replace(text.into())]).unwrap(); set_carets(&mut counted, positions, 1); let mut repeated = editor(); repeated.apply(vec![Effect::Replace(text.into())]).unwrap(); set_carets(&mut repeated, positions, 1); type_text(&mut counted, &format!("3{key}")); for _ in 0..3 { repeated.handle(Input::Char(key)); } assert_eq!(counted.carets(), repeated.carets(), "{key} {positions:?}"); assert_eq!(counted.cursor(), repeated.cursor(), "{key} {positions:?}"); } } let mut editor = editor(); editor .apply(vec![Effect::Replace( "one two three\none longer third".into(), )]) .unwrap(); editor.handle(Input::Ctrl('e')); editor.handle(Input::Char('v')); type_text(&mut editor, "2e"); assert_eq!( editor.selections(), vec![ Selection { start: Cursor { row: 0, column: 0 }, end: Cursor { row: 0, column: 7 } }, Selection { start: Cursor { row: 1, column: 0 }, end: Cursor { row: 1, column: 10 } }, ] ); editor.handle(Input::Char('c')); type_text(&mut editor, "item"); editor.handle(Input::Escape); assert_eq!(editor.lines(), &["item three", "item third"]); editor.handle(Input::Char('u')); assert_eq!(editor.lines(), &["one two three", "one longer third"]); assert_eq!(editor.selection_count(), 2); type_text(&mut editor, &format!("{}w", usize::MAX)); assert_eq!(editor.cursor(), Cursor { row: 1, column: 15 }); } #[test] fn count_digits_zero_and_escape_preserve_modal_editing_and_custom_bindings() { let mut editor = editor(); editor .apply(vec![Effect::Replace(["abc"; 12].join("\n"))]) .unwrap(); type_text(&mut editor, "10j"); assert_eq!(editor.cursor(), Cursor { row: 10, column: 0 }); editor.handle(Input::End); type_text(&mut editor, "0"); assert_eq!(editor.cursor().column, 0); editor.handle(Input::Ctrl('k')); editor.handle(Input::Char('v')); let selected = editor.selections(); type_text(&mut editor, "4"); editor.handle(Input::Escape); assert_eq!(editor.mode(), Mode::Visual); assert_eq!(editor.selections(), selected); assert_eq!(editor.count(), None); editor.handle(Input::Escape); assert_eq!(editor.mode(), Mode::Normal); assert_eq!(editor.selection_count(), 2); type_text(&mut editor, "3g"); assert_eq!(editor.count(), None); editor.bind("normal", "2", None).unwrap(); type_text(&mut editor, "42"); assert_eq!(editor.count(), None); editor .bind("normal", "3", Some("core.down".into())) .unwrap(); type_text(&mut editor, "3"); assert_eq!(editor.cursor().row, 10); assert_eq!(editor.count(), None); type_text(&mut editor, "5i12"); editor.handle(Input::Escape); assert_eq!(editor.lines()[10], "12abc"); editor.handle(Input::Char('u')); assert_eq!(editor.lines()[10], "abc"); editor.handle(Input::Char('/')); type_text(&mut editor, "123"); assert_eq!(editor.command_line(), "123"); assert_eq!(editor.count(), None); } #[test] fn counted_search_wraps_per_target_and_unrelated_actions_execute_once() { let mut editor = editor(); editor .apply(vec![Effect::Replace("cat cat cat\ncat cat cat".into())]) .unwrap(); set_carets(&mut editor, &[(0, 0), (1, 0)], 1); submit_search(&mut editor, "cat"); type_text(&mut editor, "2n"); assert_eq!( editor.carets(), vec![Cursor { row: 0, column: 0 }, Cursor { row: 1, column: 0 }] ); type_text(&mut editor, "2N"); assert_eq!( editor.carets(), vec![Cursor { row: 0, column: 4 }, Cursor { row: 1, column: 4 }] ); editor.register(plugin("once", &["once"])).unwrap(); editor.bind("normal", "g", Some("once".into())).unwrap(); type_text(&mut editor, "8"); let invocation = editor.handle(Input::Char('g')).unwrap(); assert_eq!(invocation.command, "once"); assert_eq!(editor.count(), None); type_text(&mut editor, "7"); editor.handle(Input::Ctrl('p')); type_text(&mut editor, "core.word_next"); editor.handle(Input::Enter); assert_eq!( editor.carets(), vec![Cursor { row: 0, column: 8 }, Cursor { row: 1, column: 8 }] ); assert_eq!(editor.count(), None); type_text(&mut editor, "9"); editor.handle(Input::Paste("ignored".into())); editor.handle(Input::Char('h')); assert_eq!(editor.cursor(), Cursor { row: 1, column: 7 }); } fn submit_search(editor: &mut Editor, query: &str) { editor.handle(Input::Char('/')); type_text(editor, query); editor.handle(Input::Enter); } fn plugin(name: &str, commands: &[&str]) -> Feature { Feature { name: name.into(), required_capabilities: vec!["text".into()], commands: commands.iter().map(|name| (*name).into()).collect(), core: false, } } fn set_carets(editor: &mut Editor, positions: &[(usize, usize)], primary: usize) { editor.active.mode = Mode::Normal; editor.active.visual_motions = None; editor.active.targets = positions .iter() .map(|&(row, column)| Target::caret(Cursor { row, column })) .collect(); editor.active.primary = primary; editor.normalize_targets(); } fn set_ranges(editor: &mut Editor, ranges: &[(Cursor, Cursor)], primary: usize) { editor.active.mode = Mode::Visual; editor.active.visual_motions = None; editor.active.targets = ranges .iter() .map(|&(start, end)| editor.range_target(Selection { start, end }, true)) .collect(); editor.active.primary = primary; editor.normalize_targets(); } #[test] fn repeat_visual_word_change_resolves_fresh_words_for_every_caret_and_ignores_remaps() { let mut editor = editor(); editor .apply(vec![Effect::Replace("foo\nlonger".into())]) .unwrap(); set_carets(&mut editor, &[(0, 0), (1, 0)], 1); type_text(&mut editor, "vecitem"); editor.handle(Input::Escape); editor .bind("visual", "e", Some("core.right".into())) .unwrap(); editor .apply(vec![Effect::Replace("tiny\nextraordinary".into())]) .unwrap(); set_carets(&mut editor, &[(0, 0), (1, 0)], 1); let undo = editor.active.undo.len(); type_text(&mut editor, "9."); assert_eq!(editor.context().text, "item\nitem"); assert_eq!(editor.mode(), Mode::Normal); assert_eq!(editor.selection_count(), 2); assert_eq!(editor.cursor().row, 1); assert_eq!(editor.count(), None); assert_eq!(editor.clipboard, vec!["tiny", "extraordinary"]); assert_eq!(editor.active.undo.len(), undo + 1); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "tiny\nextraordinary"); assert_eq!( editor.carets(), vec![Cursor { row: 0, column: 0 }, Cursor { row: 1, column: 0 }] ); assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 }); editor.handle(Input::Char('U')); assert_eq!(editor.context().text, "item\nitem"); editor.handle(Input::Char('u')); editor.handle(Input::Char('.')); assert_eq!(editor.context().text, "item\nitem"); } #[test] fn repeat_visual_current_ranges_supersede_unequal_recorded_spans_and_cardinality() { let mut editor = editor(); editor .apply(vec![Effect::Replace("abcd\nlonger".into())]) .unwrap(); set_ranges( &mut editor, &[ (Cursor { row: 0, column: 0 }, Cursor { row: 0, column: 2 }), (Cursor { row: 1, column: 0 }, Cursor { row: 1, column: 5 }), ], 1, ); type_text(&mut editor, "cQ"); editor.handle(Input::Escape); editor .apply(vec![Effect::Replace("alpha beta\nsecond\nthird".into())]) .unwrap(); set_ranges( &mut editor, &[ (Cursor { row: 0, column: 0 }, Cursor { row: 0, column: 10 }), (Cursor { row: 1, column: 0 }, Cursor { row: 1, column: 6 }), (Cursor { row: 2, column: 0 }, Cursor { row: 2, column: 5 }), ], 2, ); let undo = editor.active.undo.len(); editor.handle(Input::Char('.')); assert_eq!(editor.context().text, "Q\nQ\nQ"); assert_eq!(editor.mode(), Mode::Normal); assert_eq!(editor.selection_count(), 3); assert_eq!(editor.cursor().row, 2); assert_eq!(editor.active.undo.len(), undo + 1); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "alpha beta\nsecond\nthird"); } #[test] fn repeat_cut_retargets_fresh_unequal_words_without_recreating_cloned_carets() { let mut editor = editor(); editor .apply(vec![Effect::Replace("cat\nlongword".into())]) .unwrap(); type_text(&mut editor, "ve"); editor.handle(Input::Ctrl('e')); editor.handle(Input::Char('d')); editor .apply(vec![Effect::Replace("small\nhugeword\nlast".into())]) .unwrap(); set_carets(&mut editor, &[(0, 0), (1, 0), (2, 0)], 2); // Cloning invalidated the selector; both recorded spans have length three. editor.handle(Input::Char('.')); assert_eq!(editor.context().text, "ll\neword\nt"); assert_eq!(editor.selection_count(), 3); editor .apply(vec![Effect::Replace("cat\nlongword".into())]) .unwrap(); set_carets(&mut editor, &[(0, 0), (1, 0)], 1); type_text(&mut editor, "ved"); editor .apply(vec![Effect::Replace("tiny\nenormous".into())]) .unwrap(); set_carets(&mut editor, &[(0, 0), (1, 0)], 1); editor.handle(Input::Char('.')); assert_eq!(editor.context().text, "\n"); assert_eq!(editor.clipboard, vec!["tiny", "enormous"]); } #[test] fn repeat_relative_spans_count_graphemes_and_newlines_and_keep_zero_spans_empty() { let mut editor = editor(); editor .apply(vec![Effect::Replace("a界\n👩‍🚀x".into())]) .unwrap(); set_ranges( &mut editor, &[(Cursor { row: 0, column: 1 }, Cursor { row: 1, column: 1 })], 0, ); editor.handle(Input::Char('d')); editor .apply(vec![Effect::Replace("e\u{301}Z\n\n👨‍💻tail".into())]) .unwrap(); set_carets(&mut editor, &[(0, 0)], 0); editor.handle(Input::Char('.')); assert_eq!(editor.context().text, "\n👨‍💻tail"); assert_eq!(editor.clipboard, vec!["e\u{301}Z\n"]); editor.apply(vec![Effect::Replace("".into())]).unwrap(); set_ranges(&mut editor, &[(Cursor::default(), Cursor::default())], 0); type_text(&mut editor, "cQ"); editor.handle(Input::Escape); editor.apply(vec![Effect::Replace("abc".into())]).unwrap(); set_carets(&mut editor, &[(0, 0)], 0); editor.handle(Input::Char('.')); assert_eq!(editor.context().text, "Qabc"); } #[test] fn repeat_unequal_span_distribution_refuses_atomically_and_recipe_survives() { let mut editor = editor(); editor .apply(vec![Effect::Replace("abcd\nlonger".into())]) .unwrap(); set_ranges( &mut editor, &[ (Cursor { row: 0, column: 0 }, Cursor { row: 0, column: 2 }), (Cursor { row: 1, column: 0 }, Cursor { row: 1, column: 3 }), ], 1, ); editor.handle(Input::Char('d')); editor .apply(vec![Effect::Replace("1234\n56789".into())]) .unwrap(); set_carets(&mut editor, &[(0, 1)], 0); let undo = editor.active.undo.len(); let redo = editor.active.redo.len(); let clipboard = editor.clipboard.clone(); editor.handle(Input::Char('.')); assert_eq!(editor.context().text, "1234\n56789"); assert_eq!(editor.cursor(), Cursor { row: 0, column: 1 }); assert_eq!(editor.mode(), Mode::Normal); assert_eq!(editor.active.undo.len(), undo); assert_eq!(editor.active.redo.len(), redo); assert_eq!(editor.clipboard, clipboard); assert!(editor.message().contains("count")); set_carets(&mut editor, &[(0, 0), (1, 0)], 1); editor.handle(Input::Char('.')); assert_eq!(editor.context().text, "34\n89"); } #[test] fn repeat_insert_placements_recalculate_for_current_multi_caret_lines() { for key in ['i', 'I', 'a', 'A', 'o', 'O'] { let mut recorded = editor(); recorded .apply(vec![Effect::Replace("short".into())]) .unwrap(); recorded.handle(Input::Char(key)); type_text(&mut recorded, "X\nY"); recorded.handle(Input::Escape); let text = " alpha\n\tbeta\nz"; recorded.apply(vec![Effect::Replace(text.into())]).unwrap(); set_carets(&mut recorded, &[(0, 2), (1, 1), (2, 0)], 1); let mut expected = editor(); expected.apply(vec![Effect::Replace(text.into())]).unwrap(); set_carets(&mut expected, &[(0, 2), (1, 1), (2, 0)], 1); expected.handle(Input::Char(key)); type_text(&mut expected, "X\nY"); expected.handle(Input::Escape); let undo = recorded.active.undo.len(); recorded.handle(Input::Char('.')); assert_eq!(recorded.context().text, expected.context().text, "{key}"); assert_eq!(recorded.carets(), expected.carets(), "{key}"); assert_eq!(recorded.cursor(), expected.cursor(), "{key}"); assert_eq!(recorded.mode(), Mode::Normal); assert_eq!(recorded.active.undo.len(), undo + 1); recorded.handle(Input::Char('u')); assert_eq!(recorded.context().text, text, "{key}"); assert_eq!(recorded.selection_count(), 3); } } #[test] fn repeat_visual_line_insert_uses_current_carets_without_rebuilding_old_row_targets() { for (key, expected) in [('I', " !alpha\n !beta"), ('A', " alpha!\n beta!")] { let mut editor = editor(); editor .apply(vec![Effect::Replace("a\nb\nc".into())]) .unwrap(); type_text(&mut editor, "v2j"); editor.handle(Input::Char(key)); type_text(&mut editor, "!"); editor.handle(Input::Escape); assert_eq!(editor.selection_count(), 3); editor .apply(vec![Effect::Replace(" alpha\n beta".into())]) .unwrap(); set_carets(&mut editor, &[(0, 2), (1, 2)], 1); editor.handle(Input::Char('.')); assert_eq!(editor.context().text, expected); assert_eq!(editor.selection_count(), 2); assert_eq!(editor.cursor().row, 1); } } #[test] fn repeat_counted_visual_motion_uses_resolved_action_and_normalizes_overlaps() { let mut editor = editor(); editor .apply(vec![Effect::Replace("one two three".into())]) .unwrap(); type_text(&mut editor, "v2ecQ"); editor.handle(Input::Escape); editor .bind("visual", "e", Some("core.left".into())) .unwrap(); editor .apply(vec![Effect::Replace("a enormous tail".into())]) .unwrap(); set_carets(&mut editor, &[(0, 0), (0, 2)], 1); let undo = editor.active.undo.len(); editor.handle(Input::Char('.')); assert_eq!(editor.context().text, "Q"); assert_eq!(editor.selection_count(), 1); assert_eq!(editor.active.undo.len(), undo + 1); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "a enormous tail"); assert_eq!( editor.carets(), vec![Cursor { row: 0, column: 0 }, Cursor { row: 0, column: 2 }] ); assert_eq!(editor.cursor(), Cursor { row: 0, column: 2 }); } #[test] fn repeat_insert_native_motion_delete_backspace_and_newline_are_one_edit() { fn edit(editor: &mut Editor) { editor.handle(Input::Char('i')); editor.handle(Input::Right); type_text(editor, "XY"); editor.handle(Input::Left); editor.handle(Input::Backspace); editor.handle(Input::Delete); editor.handle(Input::Enter); type_text(editor, "Z"); editor.handle(Input::Right); // Trailing navigation is not part of the edit. editor.handle(Input::Escape); } let mut recorded = editor(); recorded .apply(vec![Effect::Replace("abcdef".into())]) .unwrap(); edit(&mut recorded); recorded .apply(vec![Effect::Replace("123456\nlongertext".into())]) .unwrap(); set_carets(&mut recorded, &[(0, 1), (1, 2)], 1); let mut expected = editor(); expected .apply(vec![Effect::Replace("123456\nlongertext".into())]) .unwrap(); set_carets(&mut expected, &[(0, 1), (1, 2)], 1); edit(&mut expected); let undo = recorded.active.undo.len(); recorded.handle(Input::Char('.')); assert_eq!(recorded.context().text, expected.context().text); assert_eq!(recorded.active.undo.len(), undo + 1); recorded.handle(Input::Char('u')); assert_eq!(recorded.context().text, "123456\nlongertext"); } #[test] fn repeat_omits_noop_insert_deletions_and_preserves_partial_noop_carets() { let mut editor = editor(); editor.handle(Input::Char('i')); editor.handle(Input::Backspace); editor.handle(Input::Delete); type_text(&mut editor, "X"); editor.handle(Input::Escape); editor.apply(vec![Effect::Replace("abcd".into())]).unwrap(); set_carets(&mut editor, &[(0, 2)], 0); editor.handle(Input::Char('.')); assert_eq!(editor.context().text, "abXcd"); editor.apply(vec![Effect::Replace("a\nab".into())]).unwrap(); set_carets(&mut editor, &[(0, 0), (1, 1)], 1); editor.handle(Input::Char('i')); editor.handle(Input::Backspace); type_text(&mut editor, "X"); editor.handle(Input::Escape); editor .apply(vec![Effect::Replace("cde\nfgh".into())]) .unwrap(); set_carets(&mut editor, &[(0, 2), (1, 2)], 1); editor.handle(Input::Char('.')); assert_eq!(editor.context().text, "cdXe\nfXh"); } #[test] fn repeat_partial_deletion_mismatch_rolls_back_visual_change_and_clipboard() { let mut editor = editor(); editor .apply(vec![Effect::Replace("ab\ncd".into())]) .unwrap(); set_carets(&mut editor, &[(0, 0), (1, 0)], 1); type_text(&mut editor, "vc"); editor.handle(Input::Backspace); type_text(&mut editor, "X"); editor.handle(Input::Escape); editor .apply(vec![Effect::Replace("aa\nbb\ncc".into())]) .unwrap(); set_carets(&mut editor, &[(0, 0), (1, 0), (2, 0)], 2); editor.handle(Input::Char('v')); let ranges = editor.selections(); let targets = editor.active.targets.clone(); let primary = editor.active.primary; let clipboard = editor.clipboard.clone(); let undo = editor.active.undo.len(); let redo = editor.active.redo.len(); editor.handle(Input::Char('.')); assert_eq!(editor.context().text, "aa\nbb\ncc"); assert_eq!(editor.mode(), Mode::Visual); assert_eq!(editor.selections(), ranges); assert_eq!(editor.active.targets, targets); assert_eq!(editor.active.primary, primary); assert_eq!(editor.clipboard, clipboard); assert_eq!(editor.active.undo.len(), undo); assert_eq!(editor.active.redo.len(), redo); assert!(editor.message().contains("count")); editor.handle(Input::Escape); set_carets(&mut editor, &[(0, 0), (1, 0)], 1); editor.handle(Input::Char('.')); assert_eq!(editor.context().text, "XaXb\ncc"); } #[test] fn repeat_typed_scalars_keep_right_neighbor_combining_and_zwj_boundaries() { for (typed, neighbor, result) in [("ab", "\u{301}", "a\u{301}b"), ("👩x", "\u{200d}🚀", "👩‍🚀x")] { let mut editor = editor(); editor.handle(Input::Char('i')); type_text(&mut editor, typed); editor.handle(Input::Escape); editor .apply(vec![Effect::Replace(neighbor.into())]) .unwrap(); set_carets(&mut editor, &[(0, 0)], 0); let undo = editor.active.undo.len(); editor.handle(Input::Char('.')); assert_eq!(editor.context().text, result); assert_eq!(editor.active.undo.len(), undo + 1); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, neighbor); editor.handle(Input::Char('U')); assert_eq!(editor.context().text, result); } } #[test] fn repeat_literal_paste_is_bulk_literal_and_keeps_original_typing_groups() { let mut editor = editor(); type_text(&mut editor, "iX"); editor.handle(Input::Paste("ab\r\n:q!".into())); type_text(&mut editor, "Y"); editor.handle(Input::Escape); assert_eq!(editor.context().text, "Xab\n:q!Y"); for expected in ["Xab\n:q!", "X", ""] { editor.handle(Input::Char('u')); assert_eq!(editor.context().text, expected); } editor.apply(vec![Effect::Replace("fresh".into())]).unwrap(); set_carets(&mut editor, &[(0, 0)], 0); editor.handle(Input::Char('.')); assert_eq!(editor.context().text, "Yfresh"); type_text(&mut editor, "i"); editor.handle(Input::Paste("ab".into())); editor.handle(Input::Escape); editor .apply(vec![Effect::Replace("\u{301}".into())]) .unwrap(); set_carets(&mut editor, &[(0, 0)], 0); editor.handle(Input::Char('.')); assert_eq!(editor.context().text, "ab\u{301}"); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "\u{301}"); editor.handle(Input::Char('i')); editor.handle(Input::Paste(":q!\r\n".into())); editor.handle(Input::Escape); editor .apply(vec![Effect::Replace("target".into())]) .unwrap(); set_carets(&mut editor, &[(0, 0)], 0); editor.handle(Input::Char('.')); assert_eq!(editor.context().text, ":q!\ntarget"); assert!(!editor.should_quit()); } #[test] fn repeat_clipboard_captures_fragments_pairs_broadcasts_and_refuses_mismatch() { let mut editor = editor(); editor .apply(vec![Effect::Replace("ab\nxyz".into())]) .unwrap(); set_ranges( &mut editor, &[ (Cursor { row: 0, column: 0 }, Cursor { row: 0, column: 2 }), (Cursor { row: 1, column: 0 }, Cursor { row: 1, column: 3 }), ], 1, ); editor.handle(Input::Char('y')); set_carets(&mut editor, &[(0, 0), (1, 0)], 1); editor.handle(Input::Char('P')); editor.apply(vec![Effect::Replace("OTHER".into())]).unwrap(); set_carets(&mut editor, &[(0, 0)], 0); type_text(&mut editor, "vey"); assert_eq!(editor.clipboard, vec!["OTHER"]); editor .apply(vec![Effect::Replace("1\n2\n3".into())]) .unwrap(); set_carets(&mut editor, &[(0, 0), (1, 0), (2, 0)], 2); let undo = editor.active.undo.len(); let targets = editor.active.targets.clone(); editor.handle(Input::Char('.')); assert_eq!(editor.context().text, "1\n2\n3"); assert_eq!(editor.active.targets, targets); assert_eq!(editor.active.undo.len(), undo); assert_eq!(editor.clipboard, vec!["OTHER"]); set_carets(&mut editor, &[(0, 0), (1, 0)], 1); editor.handle(Input::Char('.')); assert_eq!(editor.context().text, "ab1\nxyz2\n3"); assert_eq!(editor.clipboard, vec!["OTHER"]); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "1\n2\n3"); editor.clipboard = vec!["!".into()]; editor.handle(Input::Char('p')); editor .apply(vec![Effect::Replace("a\nb\nc".into())]) .unwrap(); set_carets(&mut editor, &[(0, 0), (1, 0), (2, 0)], 1); editor.handle(Input::Char('.')); assert_eq!(editor.context().text, "a!\nb!\nc!"); } #[test] fn repeat_join_and_normal_delete_are_native_and_noops_keep_the_previous_recipe() { let mut editor = editor(); editor.apply(vec![Effect::Replace("a\n b".into())]).unwrap(); editor.handle(Input::Char('J')); editor .apply(vec![Effect::Replace("Done.\n again\nlast".into())]) .unwrap(); set_carets(&mut editor, &[(0, 0)], 0); let undo = editor.active.undo.len(); editor.handle(Input::Char('.')); assert_eq!(editor.context().text, "Done. again\nlast"); assert_eq!(editor.active.undo.len(), undo + 1); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "Done.\n again\nlast"); editor.apply(vec![Effect::Replace("".into())]).unwrap(); editor.handle(Input::Char('J')); editor.handle(Input::Char('x')); editor .apply(vec![Effect::Replace("one\n two".into())]) .unwrap(); editor.handle(Input::Char('.')); assert_eq!(editor.context().text, "one two"); editor.handle(Input::Char('x')); editor .apply(vec![Effect::Replace("界e\u{301}\n👩‍🚀z".into())]) .unwrap(); set_carets(&mut editor, &[(0, 0), (1, 0)], 1); editor.handle(Input::Char('.')); assert_eq!(editor.context().text, "e\u{301}\nz"); assert_eq!(editor.selection_count(), 2); } #[test] fn repeat_identical_change_empty_insert_rejected_paste_and_history_keep_recipe_and_redo() { let mut editor = editor(); editor.apply(vec![Effect::Replace("abc".into())]).unwrap(); type_text(&mut editor, "iX"); editor.handle(Input::Escape); editor.handle(Input::Char('u')); type_text(&mut editor, "vecabc"); editor.handle(Input::Escape); set_carets(&mut editor, &[(0, 0)], 0); type_text(&mut editor, "i"); editor.handle(Input::Backspace); editor.handle(Input::Escape); let redo = editor.active.redo.len(); editor.handle(Input::Char('i')); editor.handle(Input::Paste("bad\0".into())); editor.handle(Input::Escape); assert_eq!(editor.active.redo.len(), redo); editor.handle(Input::Char('U')); assert_eq!(editor.context().text, "Xabc"); editor.handle(Input::Char('u')); set_carets(&mut editor, &[(0, 0)], 0); editor.handle(Input::Char('.')); assert_eq!(editor.context().text, "Xabc"); } #[test] fn repeat_is_session_wide_and_plugin_effects_commit_but_never_record_native_intent() { let directory = Directory::new(); let other = directory.path("other"); fs::write(&other, "base").unwrap(); let mut editor = editor(); type_text(&mut editor, "iN"); editor.execute(&format!("edit {}", other.display())); editor.handle(Input::Char('.')); assert_eq!(editor.context().text, "Nbase"); editor.register(plugin("fixture", &["greet"])).unwrap(); assert_eq!(editor.execute("greet").unwrap().command, "greet"); editor .apply(vec![ Effect::Replace("plugin".into()), Effect::Insert("!".into()), ]) .unwrap(); set_carets(&mut editor, &[(0, 0)], 0); assert_eq!(editor.handle(Input::Char('.')), None); assert_eq!(editor.context().text, "N!plugin"); editor.handle(Input::Char('i')); type_text(&mut editor, "T"); editor.apply(vec![Effect::Insert("P".into())]).unwrap(); editor.handle(Input::Escape); editor.apply(vec![Effect::Replace("fresh".into())]).unwrap(); set_carets(&mut editor, &[(0, 0)], 0); editor.handle(Input::Char('.')); assert_eq!(editor.context().text, "Tfresh"); editor.execute("buffer 1"); assert_eq!(editor.context().text, "N"); editor.handle(Input::Char('.')); assert_eq!(editor.context().text, "TN"); } #[test] fn repeat_action_no_recipe_remaps_and_literal_queries_do_not_record_or_execute_edits() { let directory = Directory::new(); fs::write(directory.path("file.txt"), "file").unwrap(); let mut editor = project_editor(&directory); editor.handle(Input::Char('.')); assert_eq!(editor.message(), "Nothing to repeat"); assert_eq!(editor.context().text, ""); assert!(editor.active.undo.is_empty()); editor .bind("normal", "r", Some("core.repeat_last_edit".into())) .unwrap(); editor .bind("visual", "r", Some("core.repeat_last_edit".into())) .unwrap(); assert!( editor .bind("insert", "r", Some("core.repeat_last_edit".into())) .is_err() ); type_text(&mut editor, "iX"); editor.open_file_picker(FilePresentation::Docked); editor.handle(Input::Char('i')); type_text(&mut editor, "."); assert_eq!(editor.file_picker().unwrap().query, "."); assert_eq!(editor.context().text, "X"); editor.handle(Input::Ctrl('p')); type_text(&mut editor, "."); assert_eq!(editor.palette().unwrap().query, "."); editor.handle(Input::Escape); type_text(&mut editor, "Y"); editor.handle(Input::Escape); editor.apply(vec![Effect::Replace("fresh".into())]).unwrap(); set_carets(&mut editor, &[(0, 0)], 0); editor.handle(Input::Char('r')); assert_eq!(editor.context().text, "XYfresh"); type_text(&mut editor, "i."); editor.handle(Input::Escape); editor .apply(vec![Effect::Replace("target".into())]) .unwrap(); set_carets(&mut editor, &[(0, 0)], 0); editor.handle(Input::Char('r')); assert_eq!(editor.context().text, ".target"); type_text(&mut editor, "/."); assert_eq!(editor.command_line(), "."); editor.handle(Input::Escape); type_text(&mut editor, ":."); assert_eq!(editor.command_line(), "."); editor.handle(Input::Escape); editor.apply(vec![Effect::Replace("again".into())]).unwrap(); set_carets(&mut editor, &[(0, 0)], 0); editor.handle(Input::Char('r')); assert_eq!(editor.context().text, ".again"); } #[test] fn buffers_keep_independent_history_saved_text_carets_primary_and_search() { let directory = Directory::new(); let a = directory.path("a"); let b = directory.path("b"); fs::write(&a, "cat cat\ncat cat").unwrap(); fs::write(&b, "dog dog\ndog dog").unwrap(); let mut editor = Editor::open(Some(a.clone()), vec![]).unwrap(); editor.set_clipboard_mode("separate").unwrap(); set_carets(&mut editor, &[(0, 0), (1, 0)], 1); editor.handle(Input::Char('i')); type_text(&mut editor, "X"); editor.handle(Input::Escape); submit_search(&mut editor, "cat"); let a_carets = editor.carets(); let a_primary = editor.cursor(); let a_match = editor.search_match(); editor.execute("w"); assert!(!editor.dirty()); editor.execute(&format!("edit {}", b.display())); assert_eq!(editor.buffer_id(), 2); set_carets(&mut editor, &[(0, 0), (1, 0)], 0); submit_search(&mut editor, "dog"); let b_carets = editor.carets(); let b_primary = editor.cursor(); editor.handle(Input::Char('i')); type_text(&mut editor, "Y"); editor.handle(Input::Escape); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "dog dog\ndog dog"); assert_eq!(editor.carets(), b_carets); assert_eq!(editor.cursor(), b_primary); assert!(!editor.dirty()); editor.execute("buffer 1"); assert_eq!(editor.context().text, "Xcat cat\nXcat cat"); assert_eq!(editor.carets(), a_carets); assert_eq!(editor.cursor(), a_primary); assert_eq!(editor.search_match(), a_match); editor.handle(Input::Char('n')); assert_eq!(editor.cursor(), Cursor { row: 1, column: 5 }); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "cat cat\ncat cat"); assert!(editor.dirty()); editor.handle(Input::Char('U')); assert_eq!(editor.context().text, "Xcat cat\nXcat cat"); assert!(!editor.dirty()); editor.execute("buffer 2"); editor.handle(Input::Char('n')); assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 }); editor.handle(Input::Char('U')); assert_eq!(editor.context().text, "dog Ydog\ndog Ydog"); assert!(editor.dirty()); editor.execute("w"); assert!(!editor.dirty()); assert_eq!(fs::read_to_string(&a).unwrap(), "Xcat cat\nXcat cat"); assert_eq!(fs::read_to_string(&b).unwrap(), "dog Ydog\ndog Ydog"); editor.execute("buffer 1"); assert!(!editor.dirty()); } #[test] fn explicit_switch_preserves_directed_visual_ranges_and_bound_switch_restores_them() { let directory = Directory::new(); let a = directory.path("a"); let b = directory.path("b"); fs::write(&a, "abcdef\nabcdef").unwrap(); fs::write(&b, "other").unwrap(); let mut editor = Editor::open(Some(a), vec![]).unwrap(); editor.set_clipboard_mode("separate").unwrap(); set_carets(&mut editor, &[(0, 4), (1, 4)], 1); type_text(&mut editor, "vhh"); let carets = editor.carets(); let primary = editor.cursor(); let ranges = editor.selections(); editor.execute(&format!("edit {}", b.display())); assert_eq!(editor.mode(), Mode::Normal); editor.bind("normal", "g", Some("bprev".into())).unwrap(); editor.handle(Input::Char('g')); assert_eq!(editor.buffer_id(), 1); assert_eq!(editor.mode(), Mode::Visual); assert_eq!(editor.carets(), carets); assert_eq!(editor.cursor(), primary); assert_eq!(editor.selections(), ranges); editor.execute("bnext"); editor.execute("buffer 1"); assert_eq!(editor.mode(), Mode::Visual); editor.handle(Input::Char('h')); assert_eq!(editor.cursor(), Cursor { row: 1, column: 1 }); assert_eq!(editor.selections()[1].end.column, 5); } #[test] fn switching_commits_insert_groups_and_never_restores_transient_prompts() { let directory = Directory::new(); let a = directory.path("a"); let b = directory.path("b"); fs::write(&a, "base").unwrap(); fs::write(&b, "other").unwrap(); let mut editor = Editor::open(Some(a), vec![]).unwrap(); editor.set_clipboard_mode("separate").unwrap(); editor.handle(Input::Char('i')); type_text(&mut editor, "XY"); editor.execute(&format!("edit {}", b.display())); editor.execute("buffer 1"); assert_eq!(editor.mode(), Mode::Normal); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "base"); editor.handle(Input::Char('U')); assert_eq!(editor.context().text, "XYbase"); for prompt in [':', '/'] { editor.handle(Input::Char(prompt)); type_text(&mut editor, "unfinished"); editor.execute("buffer 2"); assert_eq!(editor.command_line(), ""); editor.execute("buffer 1"); assert_eq!(editor.mode(), Mode::Normal); assert_eq!(editor.command_line(), ""); } } #[test] fn failed_opens_and_invalid_buffer_arguments_leave_editing_state_untouched() { let directory = Directory::new(); let bad = directory.path("bad"); fs::write(&bad, b"\xff").unwrap(); let controls = directory.path("controls"); fs::write(&controls, "bad\0").unwrap(); let mut editor = editor(); editor .apply(vec![Effect::Replace("abcdef".into())]) .unwrap(); type_text(&mut editor, "llvhh"); let ranges = editor.selections(); let carets = editor.carets(); let cursor = editor.cursor(); let undo = editor.active.undo.len(); let commands = [ format!("edit {}", bad.display()), format!("edit {}", controls.display()), format!("edit {}", directory.0.display()), "edit".into(), "buffer".into(), "buffer 0".into(), "buffer -1".into(), "buffer 99".into(), "buffer 1 2".into(), "bnext 1".into(), "bprev 1".into(), "buffers 1".into(), "bdelete nope".into(), "bdelete! 99".into(), "bdelete 999999999999999999999999999999".into(), ]; for command in commands { editor.execute(&command); assert_eq!(editor.buffer_count(), 1); assert_eq!(editor.buffer_id(), 1); assert_eq!(editor.context().text, "abcdef"); assert_eq!(editor.mode(), Mode::Visual); assert_eq!(editor.selections(), ranges); assert_eq!(editor.carets(), carets); assert_eq!(editor.cursor(), cursor); assert_eq!(editor.active.undo.len(), undo); } editor.handle(Input::Escape); editor.handle(Input::Char('i')); type_text(&mut editor, "X"); editor.execute(&format!("edit {}", bad.display())); assert_eq!(editor.mode(), Mode::Insert); type_text(&mut editor, "Y"); editor.handle(Input::Escape); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "abcdef"); } #[test] fn stable_buffer_ids_wrap_close_inactive_and_replace_the_last_buffer() { let directory = Directory::new(); let mut editor = editor(); let a = directory.path("new a"); let b = directory.path("new b"); editor.execute(&format!("edit {}", a.display())); editor.execute(&format!("edit {}", b.display())); assert_eq!( editor.buffers().map(|buffer| buffer.id).collect::>(), vec![1, 2, 3], ); let info = editor.buffers().find(|buffer| buffer.id == 3).unwrap(); assert_eq!(info.path, Some(b.as_path())); assert!(info.active); assert!(!info.dirty); editor.execute("bdelete 2"); assert_eq!(editor.buffer_id(), 3); assert!(!editor.has_buffer(2)); assert_eq!(editor.buffer_count(), 2); editor.execute("bnext"); assert_eq!(editor.buffer_id(), 1); editor.execute("bprev"); assert_eq!(editor.buffer_id(), 3); editor.execute("bdelete"); assert_eq!(editor.buffer_id(), 1); editor.execute("bdelete"); assert_eq!(editor.buffer_id(), 4); assert_eq!(editor.buffer_count(), 1); assert_eq!(editor.path(), None); assert_eq!(editor.context().text, ""); assert!(!editor.dirty()); assert!(!editor.should_quit()); editor.execute("bnext"); editor.execute("bprev"); assert_eq!(editor.buffer_id(), 4); editor.execute(&format!("edit {}", a.display())); assert_eq!(editor.buffer_id(), 5); assert_eq!(editor.buffer_count(), 2); editor.execute("buffer 2"); assert_eq!(editor.buffer_id(), 5); } #[test] fn hidden_dirty_buffers_block_quit_write_quit_and_unforced_deletion() { let directory = Directory::new(); let a = directory.path("a"); let b = directory.path("b"); fs::write(&a, "a").unwrap(); fs::write(&b, "b").unwrap(); let mut editor = Editor::open(Some(a), vec![]).unwrap(); editor.set_clipboard_mode("separate").unwrap(); editor .apply(vec![Effect::Replace("dirty a".into())]) .unwrap(); editor.execute(&format!("edit {}", b.display())); assert!(!editor.dirty()); editor.execute("q"); assert!(!editor.should_quit()); editor .apply(vec![Effect::Replace("saved b".into())]) .unwrap(); editor.execute("wq"); assert!(!editor.should_quit()); assert!(!editor.dirty()); assert_eq!(fs::read_to_string(&b).unwrap(), "saved b"); editor.execute("bdelete 1"); assert!(editor.has_buffer(1)); assert_eq!(editor.buffer_id(), 2); editor.execute("buffer 1"); assert_eq!(editor.context().text, "dirty a"); editor.execute("bdelete"); assert_eq!(editor.buffer_id(), 1); editor.execute("bdelete!"); assert_eq!(editor.buffer_id(), 2); editor.execute("q"); assert!(editor.should_quit()); let mut forced = Editor::open(Some(b), vec![]).unwrap(); forced.set_clipboard_mode("separate").unwrap(); forced.apply(vec![Effect::Replace("dirty".into())]).unwrap(); forced.execute(&format!("edit {}", directory.path("new").display())); forced.execute("q! arguments"); assert!(!forced.should_quit()); forced.execute("bdelete! 1"); assert_eq!(forced.buffer_id(), 2); assert!(!forced.has_buffer(1)); forced .apply(vec![Effect::Replace("also dirty".into())]) .unwrap(); forced.execute("bdelete!"); assert_eq!(forced.buffer_id(), 3); assert!(!forced.dirty()); forced .apply(vec![Effect::Replace("quit dirty".into())]) .unwrap(); forced.execute(&format!("edit {}", directory.path("another").display())); forced.execute("q!"); assert!(forced.should_quit()); } #[test] fn loaded_paths_refuse_save_collisions_before_touching_files_or_saved_state() { let directory = Directory::new(); let a = directory.path("a"); let b = directory.path("b"); fs::write(&a, "a").unwrap(); fs::write(&b, "b").unwrap(); let mut editor = Editor::open(Some(a.clone()), vec![]).unwrap(); editor.set_clipboard_mode("separate").unwrap(); editor.execute(&format!("edit {}", b.display())); editor .apply(vec![Effect::Replace("dirty b".into())]) .unwrap(); editor.execute(&format!("w {}", directory.0.join("./a").display())); assert!(editor.message().contains("belongs to buffer 1")); assert_eq!(editor.path(), Some(b.as_path())); assert!(editor.dirty()); assert_eq!(fs::read_to_string(&a).unwrap(), "a"); assert_eq!(fs::read_to_string(&b).unwrap(), "b"); assert_eq!(fs::read_dir(&directory.0).unwrap().count(), 2); editor.execute(&format!("wq {}", a.display())); assert!(!editor.should_quit()); assert!(editor.dirty()); editor.execute(&format!("w {}", directory.0.join("./b").display())); assert!(!editor.dirty()); assert_eq!(fs::read_to_string(&b).unwrap(), "dirty b"); let new = directory.path("missing"); editor.execute(&format!("edit {}", new.display())); editor.execute("buffer 2"); editor.execute(&format!("w {}", new.display())); assert!(editor.message().contains("belongs to buffer 3")); assert!(!new.exists()); assert!(!editor.dirty()); } #[test] fn clipboard_bindings_plugins_and_palette_catalog_are_session_wide() { let directory = Directory::new(); let mut editor = editor(); editor.register(plugin("fixture", &["greet"])).unwrap(); editor.bind("normal", "g", Some("greet".into())).unwrap(); editor.bind("normal", "z", Some("bnext".into())).unwrap(); editor .apply(vec![Effect::Replace("copied".into())]) .unwrap(); type_text(&mut editor, "v$y"); editor.execute(&format!("edit {}", directory.path("new").display())); editor.handle(Input::Char('p')); assert_eq!(editor.context().text, "copied"); assert_eq!(editor.handle(Input::Char('g')).unwrap().plugin, "fixture"); editor.handle(Input::Char('z')); assert_eq!(editor.buffer_id(), 1); assert_eq!(editor.handle(Input::Char('g')).unwrap().command, "greet"); editor.handle(Input::Ctrl('p')); for command in [ "edit", "buffers", "buffer", "bnext", "bprev", "bdelete", "bdelete!", ] { let entry = palette_entry(&editor, command); assert_eq!(entry.owner, "core"); assert!(entry.enabled); } assert_eq!( palette_entry(&editor, "bnext").bindings, vec!["Space b n", "z"] ); editor.disable_plugin("fixture", "failure"); assert!( !editor .palette() .unwrap() .entries .iter() .any(|entry| entry.name == "greet") ); editor.handle(Input::Escape); editor.execute("buffer 2"); assert_eq!(editor.handle(Input::Char('g')), None); editor.execute("plugins"); assert!(!editor.message().contains("fixture")); assert!(editor.message().contains("bdelete!")); } #[cfg(unix)] #[test] fn canonical_aliases_reuse_dirty_buffers_and_new_paths_are_normalized() { use std::os::unix::fs::symlink; let directory = Directory::new(); let path = directory.path("file"); let link = directory.path("file link"); fs::write(&path, "disk").unwrap(); symlink(&path, &link).unwrap(); let mut editor = Editor::open(Some(link.clone()), vec![]).unwrap(); editor.set_clipboard_mode("separate").unwrap(); editor .apply(vec![Effect::Replace("dirty memory".into())]) .unwrap(); editor.execute(&format!("edit {}", directory.path("other").display())); fs::write(&path, "changed externally").unwrap(); editor.execute(&format!("edit {}", path.display())); assert_eq!(editor.buffer_id(), 1); assert_eq!(editor.buffer_count(), 2); assert_eq!(editor.path(), Some(link.as_path())); assert_eq!(editor.context().text, "dirty memory"); assert!(editor.dirty()); editor.execute("w"); assert!(editor.message().contains("symbolic link")); assert!(editor.dirty()); assert_eq!(fs::read_to_string(&path).unwrap(), "changed externally"); editor.execute(&format!("w {}", path.display())); assert!(!editor.dirty()); assert_eq!(editor.path(), Some(path.as_path())); let alias = directory.path("directory link"); symlink(&directory.0, &alias).unwrap(); let new = alias.join("new"); editor.execute(&format!("edit {}", new.display())); assert_eq!(editor.buffer_id(), 3); editor .apply(vec![Effect::Replace("new memory".into())]) .unwrap(); editor.execute("buffer 1"); editor.execute(&format!( "edit {}", directory.0.join("absent/../new").display() )); assert_eq!(editor.buffer_id(), 3); assert_eq!(editor.buffer_count(), 3); assert_eq!(editor.context().text, "new memory"); assert_eq!(editor.path(), Some(new.as_path())); editor.execute(&format!("w {}", link.display())); assert!(editor.message().contains("belongs to buffer 1")); assert!(editor.dirty()); assert_eq!(fs::read_to_string(&path).unwrap(), "dirty memory"); assert!(!new.exists()); editor.execute("buffers"); assert!(editor.message().contains("1 ")); assert!(editor.message().contains("3%+ ")); let control_path = directory.path("line\nbreak"); editor.execute(&format!("edit {}", control_path.display())); editor.execute("buffers"); assert!(!editor.message().chars().any(char::is_control)); assert!(editor.message().contains("line break")); } fn palette_entry<'a>(editor: &'a Editor, name: &str) -> &'a PaletteEntry { editor .palette() .unwrap() .entries .iter() .find(|entry| entry.name == name) .unwrap() } #[test] fn palette_cancellation_preserves_every_underlying_mode_and_insert_group() { for mode in [ Mode::Normal, Mode::Insert, Mode::Command, Mode::Search, Mode::Visual, ] { for cancel in [Input::Escape, Input::Ctrl('p')] { let mut editor = editor(); let original = "alpha beta\nalpha beta"; editor .apply(vec![Effect::Replace(original.into())]) .unwrap(); editor.apply(vec![Effect::Insert("extra".into())]).unwrap(); editor.undo(); submit_search(&mut editor, "alpha"); set_carets(&mut editor, &[(0, 0), (1, 0)], 1); match mode { Mode::Normal => {} Mode::Insert => { editor.handle(Input::Char('i')); type_text(&mut editor, "X"); } Mode::Command => { editor.handle(Input::Char(':')); type_text(&mut editor, "w output"); } Mode::Search => { editor.handle(Input::Char('/')); type_text(&mut editor, "alpha e\u{301}"); } Mode::Visual => { editor.handle(Input::Char('v')); editor.handle(Input::Right); } } let snapshot = |state: &Snapshot| (state.lines.clone(), state.targets.clone(), state.primary); let lines = editor.active.lines.clone(); let targets = editor.active.targets.clone(); let primary = editor.active.primary; let selections = editor.selections(); let prompt = editor.command_line.clone(); let search = editor.active.search_query.clone(); let found = editor.active.search_match; let message = editor.message.clone(); let undo: Vec<_> = editor.active.undo.iter().map(&snapshot).collect(); let redo: Vec<_> = editor.active.redo.iter().map(&snapshot).collect(); let insert = editor.active.insert_start.as_ref().map(snapshot); editor.handle(Input::Ctrl('p')); assert!(editor.palette().is_some()); editor.handle(Input::Ctrl('j')); editor.handle(Input::Ctrl('k')); type_text(&mut editor, "query Δe\u{301}👩‍🚀 "); editor.handle(Input::Backspace); editor.handle(Input::Backspace); assert_eq!(editor.palette().unwrap().query, "query Δe\u{301}"); editor.handle(Input::Paste("ignored\n:q!".into())); editor.handle(Input::Down); editor.handle(Input::Ctrl('k')); editor.handle(cancel); assert!(editor.palette().is_none()); assert_eq!(editor.mode(), mode); assert_eq!(editor.active.lines, lines); assert_eq!(editor.active.targets, targets); assert_eq!(editor.active.primary, primary); assert_eq!(editor.selections(), selections); assert_eq!(editor.command_line, prompt); assert_eq!(editor.active.search_query, search); assert_eq!(editor.active.search_match, found); assert_eq!(editor.message, message); assert_eq!( editor.active.undo.iter().map(&snapshot).collect::>(), undo ); assert_eq!( editor.active.redo.iter().map(&snapshot).collect::>(), redo ); assert_eq!(editor.active.insert_start.as_ref().map(snapshot), insert); if mode == Mode::Insert { type_text(&mut editor, "Y"); editor.handle(Input::Escape); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, original); } } } } #[test] fn palette_filters_all_tokens_and_wraps_without_executing_empty_results() { let mut editor = editor(); editor .register(plugin("Älpha Tools", &["greet_one", "greet_two"])) .unwrap(); editor.handle(Input::Ctrl('p')); let order: Vec<_> = editor .palette() .unwrap() .entries .iter() .map(|entry| (entry.label.clone(), entry.name.clone())) .collect(); assert!(order.windows(2).all(|pair| pair[0] <= pair[1])); assert!( !editor .palette() .unwrap() .entries .iter() .any(|entry| entry.name == "core.palette") ); type_text(&mut editor, "GREET ÄLPHA"); assert_eq!(editor.palette().unwrap().entries.len(), 2); assert_eq!(editor.palette().unwrap().entries[0].name, "greet_one"); editor.handle(Input::Up); assert_eq!(editor.palette().unwrap().selected, 1); editor.handle(Input::Ctrl('n')); assert_eq!(editor.palette().unwrap().selected, 0); editor.handle(Input::Down); assert_eq!(editor.palette().unwrap().selected, 1); editor.handle(Input::Ctrl('k')); assert_eq!(editor.palette().unwrap().selected, 0); editor.handle(Input::Down); editor.handle(Input::Char(' ')); assert_eq!(editor.palette().unwrap().selected, 0); type_text(&mut editor, "missing"); assert!(editor.palette().unwrap().entries.is_empty()); for input in [ Input::Enter, Input::Up, Input::Down, Input::Ctrl('j'), Input::Ctrl('k'), Input::Ctrl('n'), ] { assert_eq!(editor.handle(input), None); assert_eq!(editor.palette().unwrap().selected, 0); } while !editor.palette().unwrap().query.is_empty() { editor.handle(Input::Backspace); } type_text(&mut editor, "e\u{301}👩‍🚀"); editor.handle(Input::Backspace); assert_eq!(editor.palette().unwrap().query, "e\u{301}"); editor.handle(Input::Backspace); assert_eq!(editor.palette().unwrap().query, ""); assert!(!editor.palette().unwrap().entries.is_empty()); editor.handle(Input::Ctrl('i')); assert_eq!(editor.palette().unwrap().query, ""); } #[test] fn palette_catalog_shares_binding_eligibility_and_keeps_disabled_rows() { let mut names = HashSet::new(); for &(name, action, modes) in CORE_ACTIONS { assert!(names.insert(name)); for mode in [ Mode::Normal, Mode::Insert, Mode::Command, Mode::Search, Mode::Visual, ] { assert!(Action::named(mode, name) == modes.contains(&mode).then_some(action)); } } for mode in [Mode::Insert, Mode::Command, Mode::Search, Mode::Visual] { let mut editor = editor(); editor.active.mode = mode; editor.command_line = "untouched".into(); editor.handle(Input::Ctrl('p')); type_text(&mut editor, "core.undo"); let entry = palette_entry(&editor, "core.undo"); assert!(!entry.enabled); assert_eq!(entry.modes, vec![Mode::Normal]); assert!(entry.bindings.is_empty()); editor.handle(Input::Ctrl('j')); editor.handle(Input::Ctrl('k')); assert_eq!(editor.palette().unwrap().query, "core.undo"); assert_eq!(editor.handle(Input::Enter), None); assert!(editor.palette().is_some()); assert_eq!(editor.mode(), mode); assert_eq!(editor.command_line(), "untouched"); while !editor.palette().unwrap().query.is_empty() { editor.handle(Input::Backspace); } type_text(&mut editor, "q!"); assert_eq!(editor.palette().unwrap().entries.len(), 1); assert!(!palette_entry(&editor, "q!").enabled); editor.handle(Input::Enter); assert!(!editor.should_quit()); assert!(editor.palette().is_some()); } } #[test] fn palette_effective_bindings_refresh_with_canonical_terminal_names() { let mut editor = editor(); editor.handle(Input::Ctrl('p')); assert_eq!(palette_entry(&editor, "core.insert").bindings, vec!["i"]); editor.bind("normal", "i", None).unwrap(); assert!(palette_entry(&editor, "core.insert").bindings.is_empty()); for key in [ "shift+i", "ctrl+m", "ctrl+h", "ctrl+i", "space", "ctrl+r", "界", ] { editor .bind("normal", key, Some("core.insert".into())) .unwrap(); } editor .bind("normal", "ctrl+j", Some("core.insert".into())) .unwrap(); assert_eq!( palette_entry(&editor, "core.insert").bindings, vec![ "Ctrl+h", "Ctrl+j", "Ctrl+r", "Enter", "I", "Space", "Tab", "界" ] ); assert!( palette_entry(&editor, "core.insert_first_nonblank") .bindings .is_empty() ); editor.bind("normal", "ENTER", None).unwrap(); assert!( !palette_entry(&editor, "core.insert") .bindings .iter() .any(|key| key == "Enter") ); editor .bind("normal", "界", Some("core.undo".into())) .unwrap(); assert_eq!( palette_entry(&editor, "core.undo").bindings, vec!["u", "界"] ); assert!( !palette_entry(&editor, "core.insert") .bindings .iter() .any(|key| key == "界") ); assert!( editor .bind("insert", "g", Some("core.undo".into())) .is_err() ); editor.handle(Input::Escape); editor.bind("normal", "ctrl+p", None).unwrap(); editor .bind("normal", "ctrl+f", Some("core.palette".into())) .unwrap(); editor.handle(Input::Ctrl('p')); assert!(editor.palette().is_none()); editor.handle(Input::Ctrl('f')); assert!(editor.palette().is_some()); editor.handle(Input::Ctrl('p')); assert!(editor.palette().is_none()); } #[test] fn palette_registered_commands_refresh_and_retire_with_plugins() { let mut editor = editor(); editor.handle(Input::Ctrl('p')); type_text(&mut editor, "fixture"); assert!(editor.palette().unwrap().entries.is_empty()); editor.register(plugin("fixture", &["greet"])).unwrap(); assert_eq!(editor.palette().unwrap().entries.len(), 1); assert_eq!(palette_entry(&editor, "greet").owner, "fixture"); assert!(palette_entry(&editor, "greet").enabled); editor.bind("normal", "g", Some("greet".into())).unwrap(); assert_eq!(palette_entry(&editor, "greet").bindings, vec!["g"]); editor.disable_plugin("fixture", "retired"); assert!(editor.palette().unwrap().entries.is_empty()); editor.handle(Input::Enter); assert!(editor.palette().is_some()); editor.handle(Input::Escape); assert_eq!(editor.handle(Input::Char('g')), None); assert!(editor.bind("normal", "g", Some("greet".into())).is_err()); } #[test] fn palette_commands_require_prompt_submission_and_dispatch_arguments_once() { let mut editor = editor(); editor.register(plugin("fixture", &["greet"])).unwrap(); editor.handle(Input::Ctrl('p')); type_text(&mut editor, "greet"); assert_eq!(editor.handle(Input::Enter), None); assert!(editor.palette().is_none()); assert_eq!(editor.mode(), Mode::Command); assert_eq!(editor.command_line(), "greet "); type_text(&mut editor, "界!"); assert_eq!( editor.handle(Input::Enter), Some(Invocation { plugin: "fixture".into(), command: "greet".into(), args: "界!".into(), }) ); assert_eq!(editor.mode(), Mode::Normal); assert_eq!(editor.handle(Input::Enter), None); editor.handle(Input::Ctrl('p')); type_text(&mut editor, "q!"); editor.handle(Input::Enter); assert_eq!(editor.command_line(), "q! "); assert!(!editor.should_quit()); editor.handle(Input::Enter); assert!(editor.should_quit()); } #[test] fn palette_actions_dispatch_visual_change_insert_newline_and_original_prompts() { let mut editor = editor(); editor .apply(vec![Effect::Replace("alpha beta".into())]) .unwrap(); set_ranges( &mut editor, &[(Cursor::default(), Cursor { row: 0, column: 5 })], 0, ); editor.handle(Input::Ctrl('p')); type_text(&mut editor, "core.change"); assert!(palette_entry(&editor, "core.change").enabled); editor.handle(Input::Enter); assert!(editor.palette().is_none()); assert_eq!(editor.mode(), Mode::Insert); assert_eq!(editor.context().text, " beta"); type_text(&mut editor, "Ω"); editor.handle(Input::Escape); assert_eq!(editor.context().text, "Ω beta"); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "alpha beta"); let mut insertion = self::editor(); insertion.handle(Input::Char('i')); type_text(&mut insertion, "A"); insertion.handle(Input::Ctrl('p')); type_text(&mut insertion, "core.newline"); insertion.handle(Input::Enter); assert_eq!(insertion.mode(), Mode::Insert); assert_eq!(insertion.lines(), &["A", ""]); type_text(&mut insertion, "B"); insertion.handle(Input::Escape); insertion.handle(Input::Char('u')); assert_eq!(insertion.context().text, ""); insertion.handle(Input::Char('U')); assert_eq!(insertion.context().text, "A\nB"); editor.handle(Input::Char(':')); type_text(&mut editor, "plugins"); editor.handle(Input::Ctrl('p')); type_text(&mut editor, "core.submit"); editor.handle(Input::Enter); assert_eq!(editor.mode(), Mode::Normal); assert!(editor.message().starts_with("Capabilities: ")); assert_eq!(editor.command_line(), ""); editor.handle(Input::Char('/')); type_text(&mut editor, "beta"); editor.handle(Input::Ctrl('p')); type_text(&mut editor, "core.submit"); editor.handle(Input::Enter); assert_eq!(editor.mode(), Mode::Normal); assert_eq!(editor.active.search_query, "beta"); assert_eq!(editor.cursor(), Cursor { row: 0, column: 6 }); } #[test] fn palette_survives_rejected_effects_but_external_execution_closes_it() { let mut editor = editor(); editor .apply(vec![Effect::Replace("alpha beta".into())]) .unwrap(); editor.handle(Input::Char('v')); editor.handle(Input::Right); let targets = editor.active.targets.clone(); editor.handle(Input::Ctrl('p')); type_text(&mut editor, "core.change"); let undo = editor.active.undo.len(); assert!( editor .apply(vec![ Effect::Message("changed".into()), Effect::Insert("\0".into()) ]) .is_err() ); assert_eq!(editor.palette().unwrap().query, "core.change"); assert_eq!(editor.mode(), Mode::Visual); assert_eq!(editor.active.targets, targets); assert_eq!(editor.active.undo.len(), undo); editor.apply(vec![Effect::Message("ok".into())]).unwrap(); assert_eq!(editor.palette().unwrap().query, "core.change"); assert_eq!(editor.active.targets, targets); editor.execute("plugins"); assert!(editor.palette().is_none()); assert_eq!(editor.mode(), Mode::Normal); editor.handle(Input::Char('v')); editor.handle(Input::Ctrl('p')); type_text(&mut editor, "core.change"); editor.apply(vec![Effect::Insert("!".into())]).unwrap(); assert_eq!(editor.mode(), Mode::Normal); assert!(!palette_entry(&editor, "core.change").enabled); } #[test] fn visual_change_replaces_reversed_multiline_graphemes_in_one_undo_group() { let mut editor = editor(); let original = "pre界e\u{301}\n\n👩‍🚀tail\npost"; editor .apply(vec![Effect::Replace(original.into())]) .unwrap(); submit_search(&mut editor, "👩‍🚀"); editor.handle(Input::Char('v')); editor.handle(Input::Up); editor.handle(Input::Up); for _ in 0..3 { editor.handle(Input::Right); } editor.handle(Input::Char('c')); assert_eq!(editor.mode(), Mode::Insert); assert!(editor.selections().is_empty()); assert_eq!(editor.search_match(), None); assert_eq!(editor.cursor(), Cursor { row: 0, column: 3 }); assert_eq!(editor.context().text, "pretail\npost"); editor.handle(Input::Paste("\0".into())); type_text(&mut editor, "α\nβ"); editor.handle(Input::Escape); assert_eq!(editor.context().text, "preα\nβtail\npost"); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, original); assert_eq!(editor.mode(), Mode::Normal); editor.handle(Input::Char('U')); assert_eq!(editor.context().text, "preα\nβtail\npost"); } #[test] fn visual_change_at_eof_and_empty_ranges_preserves_clipboard() { let mut editor = editor(); editor.apply(vec![Effect::Replace("abX".into())]).unwrap(); editor.handle(Input::End); editor.handle(Input::Char('v')); editor.handle(Input::Char('c')); assert_eq!(editor.cursor(), Cursor { row: 0, column: 2 }); type_text(&mut editor, "YZ"); editor.handle(Input::Escape); assert_eq!(editor.context().text, "abYZ"); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "abX"); editor.apply(vec![Effect::Replace("".into())]).unwrap(); editor.handle(Input::Char('v')); assert_eq!(editor.selections()[0].start, editor.selections()[0].end); editor.handle(Input::Char('c')); type_text(&mut editor, "new"); editor.handle(Input::Escape); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, ""); editor.handle(Input::Char('p')); assert_eq!(editor.context().text, "X"); } #[test] fn visual_change_with_identical_replacement_preserves_redo() { let mut editor = editor(); editor.apply(vec![Effect::Replace("abc".into())]).unwrap(); editor.handle(Input::Char('i')); type_text(&mut editor, "X"); editor.handle(Input::Escape); editor.handle(Input::Char('u')); editor.handle(Input::Char('v')); editor.handle(Input::Char('e')); editor.handle(Input::Char('c')); type_text(&mut editor, "abc"); editor.handle(Input::Escape); editor.handle(Input::Char('U')); assert_eq!(editor.context().text, "Xabc"); } #[test] fn visual_ranges_copy_both_directions_and_cut_and_paste_multiline_graphemes() { let mut editor = editor(); let text = "a界e\u{301}z\n\n👩‍🚀tail\n"; editor.apply(vec![Effect::Replace(text.into())]).unwrap(); editor.handle(Input::Right); editor.handle(Input::Char('v')); editor.handle(Input::Right); assert_eq!( editor.selections(), vec![Selection { start: Cursor { row: 0, column: 1 }, end: Cursor { row: 0, column: 3 }, }] ); editor.handle(Input::Left); editor.handle(Input::Left); assert_eq!( editor.selections()[0], Selection { start: Cursor { row: 0, column: 0 }, end: Cursor { row: 0, column: 2 }, } ); editor.handle(Input::Down); assert_eq!(editor.selections()[0].end, Cursor { row: 2, column: 0 }); let selected = editor.selections(); editor.handle(Input::Paste(":q!\n".into())); assert_eq!(editor.selections(), selected); editor.handle(Input::Down); editor.handle(Input::Char('d')); assert!(editor.selections().is_empty()); assert_eq!(editor.context().text, "atail\n"); editor.handle(Input::Char('P')); assert_eq!(editor.context().text, text); editor.handle(Input::Char('u')); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, text); assert_eq!(editor.mode(), Mode::Normal); } #[test] fn clipboard_copy_preserves_redo_and_survives_history_and_grapheme_merging() { let mut editor = editor(); editor .apply(vec![Effect::Replace("\u{301}".into())]) .unwrap(); editor.handle(Input::Char('v')); editor.handle(Input::Char('y')); editor.handle(Input::Char('u')); editor.handle(Input::Char('U')); assert_eq!(editor.context().text, "\u{301}"); editor.apply(vec![Effect::Replace("a".into())]).unwrap(); editor.handle(Input::Char('p')); assert_eq!(editor.context().text, "a\u{301}"); assert_eq!(editor.cursor(), Cursor::default()); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "a"); } #[test] fn visual_empty_lines_include_real_newlines_and_row_split_excludes_exclusive_end() { let mut editor = editor(); editor .apply(vec![Effect::Replace("ab\n\ncd\n".into())]) .unwrap(); editor.handle(Input::Down); editor.handle(Input::Char('v')); assert_eq!( editor.selections(), vec![Selection { start: Cursor { row: 1, column: 0 }, end: Cursor { row: 2, column: 0 }, }] ); editor.handle(Input::Ctrl('l')); assert_eq!(editor.carets(), vec![Cursor { row: 1, column: 0 }]); editor.handle(Input::Char('v')); editor.handle(Input::Delete); assert_eq!(editor.context().text, "ab\ncd\n"); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "ab\n\ncd\n"); } #[test] fn batch_insert_newline_backspace_and_delete_use_prebuffer_offsets() { let mut editor = editor(); editor.apply(vec![Effect::Replace("abcd".into())]).unwrap(); set_carets(&mut editor, &[(0, 0), (0, 2)], 1); editor.handle(Input::Char('i')); type_text(&mut editor, "X"); assert_eq!(editor.context().text, "XabXcd"); editor.handle(Input::Enter); assert_eq!(editor.context().text, "X\nabX\ncd"); editor.handle(Input::Backspace); assert_eq!(editor.context().text, "XabXcd"); editor.handle(Input::Delete); assert_eq!(editor.context().text, "XbXd"); editor.handle(Input::Escape); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "abcd"); assert_eq!( editor.carets(), vec![Cursor { row: 0, column: 0 }, Cursor { row: 0, column: 2 }] ); assert_eq!(editor.cursor(), Cursor { row: 0, column: 2 }); } #[test] fn visual_insert_and_append_split_ragged_rows_without_padding() { for (key, expected) in [('I', " !aa\n!\n !zzz"), ('A', " aa!\n!\n zzz!")] { let mut editor = editor(); editor .apply(vec![Effect::Replace(" aa\n\n zzz".into())]) .unwrap(); editor.handle(Input::Char('v')); editor.handle(Input::Down); editor.handle(Input::Down); editor.handle(Input::Char(key)); assert_eq!(editor.mode(), Mode::Insert); assert_eq!(editor.selection_count(), 3); type_text(&mut editor, "!"); editor.handle(Input::Escape); assert_eq!(editor.context().text, expected); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, " aa\n\n zzz"); } } #[test] fn unequal_word_changes_and_typing_share_undo_and_restore_primary_carets() { let mut editor = editor(); editor .apply(vec![Effect::Replace("foo\nlonger".into())]) .unwrap(); editor.handle(Input::Ctrl('e')); editor.handle(Input::Char('v')); editor.handle(Input::Char('e')); assert_eq!( editor .selections() .iter() .map(|range| range.end.column) .collect::>(), vec![3, 6] ); let before = editor.carets(); let primary = editor.cursor(); editor.handle(Input::Char('c')); type_text(&mut editor, "X"); editor.handle(Input::Escape); assert_eq!(editor.context().text, "X\nX"); assert_eq!(editor.clipboard, vec!["foo", "longer"]); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "foo\nlonger"); assert_eq!(editor.carets(), before); assert_eq!(editor.cursor(), primary); assert_eq!(editor.mode(), Mode::Normal); assert!(editor.selections().is_empty()); editor.handle(Input::Char('U')); assert_eq!(editor.context().text, "X\nX"); } #[test] fn unicode_overlap_merges_but_adjacency_preserves_distinct_fragments() { let mut editor = editor(); editor .apply(vec![Effect::Replace("a\u{301}👩‍🚀界z".into())]) .unwrap(); editor.active.mode = Mode::Visual; editor.active.targets = vec![ editor.range_target( Selection { start: Cursor { row: 0, column: 0 }, end: Cursor { row: 0, column: 2 }, }, true, ), editor.range_target( Selection { start: Cursor { row: 0, column: 1 }, end: Cursor { row: 0, column: 3 }, }, false, ), editor.range_target( Selection { start: Cursor { row: 0, column: 3 }, end: Cursor { row: 0, column: 4 }, }, true, ), ]; editor.active.primary = 1; editor.normalize_targets(); assert_eq!(editor.selection_count(), 2); assert_eq!(editor.selections()[0].end.column, 3); assert_eq!(editor.cursor().column, 0); editor.handle(Input::Char('d')); assert_eq!(editor.context().text, ""); assert_eq!(editor.clipboard, vec!["a\u{301}👩‍🚀界", "z"]); assert_eq!(editor.selection_count(), 1); editor.handle(Input::Char('u')); assert_eq!( editor.carets(), vec![Cursor { row: 0, column: 0 }, Cursor { row: 0, column: 3 }] ); } #[test] fn clipboard_pairs_in_document_order_broadcasts_one_and_refuses_mismatch_atomically() { let mut editor = editor(); editor .apply(vec![Effect::Replace("ab\nxyzq".into())]) .unwrap(); set_ranges( &mut editor, &[ (Cursor { row: 0, column: 0 }, Cursor { row: 0, column: 1 }), (Cursor { row: 1, column: 0 }, Cursor { row: 1, column: 3 }), ], 1, ); editor.handle(Input::Char('d')); assert_eq!(editor.context().text, "b\nq"); editor.handle(Input::Char('P')); assert_eq!(editor.context().text, "ab\nxyzq"); editor.handle(Input::Char('u')); editor.handle(Input::Escape); let before = editor.snapshot(); let undo = editor.active.undo.len(); let redo = editor.active.redo.len(); editor.handle(Input::Char('p')); assert_eq!(editor.active.lines, before.lines); assert_eq!(editor.active.targets, before.targets); assert_eq!(editor.active.primary, before.primary); assert_eq!( (editor.active.undo.len(), editor.active.redo.len()), (undo, redo) ); assert!(editor.message().contains("fragment count")); editor.clipboard = vec!["!".into()]; set_carets(&mut editor, &[(0, 0), (1, 0)], 0); editor.handle(Input::Char('P')); assert_eq!(editor.context().text, "!b\n!q"); } #[test] fn literal_multiline_occurrences_skip_wrap_select_all_and_escape_in_two_stages() { let mut editor = editor(); editor .apply(vec![Effect::Replace("α\nβ α\nβ α\nβ".into())]) .unwrap(); set_ranges( &mut editor, &[(Cursor { row: 0, column: 0 }, Cursor { row: 1, column: 1 })], 0, ); editor.handle(Input::Ctrl('n')); assert_eq!(editor.selection_count(), 2); assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 }); editor.handle(Input::Ctrl('s')); assert_eq!(editor.selection_count(), 2); assert_eq!(editor.cursor(), Cursor { row: 3, column: 0 }); editor.handle(Input::Ctrl('n')); assert_eq!(editor.selection_count(), 3); assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 }); editor.handle(Input::Ctrl('a')); assert_eq!(editor.selection_count(), 3); editor.handle(Input::Escape); assert_eq!(editor.mode(), Mode::Normal); assert_eq!(editor.selection_count(), 3); let primary = editor.cursor(); editor.handle(Input::Escape); assert_eq!(editor.carets(), vec![primary]); } #[test] fn occurrences_reject_grapheme_intersections_and_gather_nonoverlapping_literals() { let mut editor = editor(); editor .apply(vec![Effect::Replace("aa aa aaa a\u{301}a".into())]) .unwrap(); editor.handle(Input::Char('v')); editor.handle(Input::Right); editor.handle(Input::Ctrl('a')); assert_eq!( editor .selections() .iter() .map(|range| range.start.column) .collect::>(), vec![0, 3, 6] ); assert_eq!(editor.cursor().column, 1); editor.handle(Input::Ctrl('n')); assert_eq!(editor.selection_count(), 3); } #[test] fn vertical_clones_keep_display_columns_through_tabs_wide_and_short_rows() { let mut editor = editor(); editor .apply(vec![Effect::Replace("a\t界z\nx\n123456z".into())]) .unwrap(); editor.handle(Input::End); editor.handle(Input::Ctrl('e')); assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 }); editor.handle(Input::Ctrl('e')); assert_eq!(editor.selection_count(), 3); assert_eq!(editor.cursor(), Cursor { row: 2, column: 6 }); editor.handle(Input::Char('i')); type_text(&mut editor, "!"); editor.handle(Input::Escape); assert_eq!(editor.context().text, "a\t界!z\n!x\n123456!z"); assert_eq!(editor.selection_count(), 3); editor.handle(Input::Escape); assert_eq!(editor.selection_count(), 1); } #[test] fn visual_clones_preserve_directed_ranges_and_motion_targets_every_range() { let mut editor = editor(); editor .apply(vec![Effect::Replace("abc\nabcd\nabcde".into())]) .unwrap(); editor.handle(Input::Right); editor.handle(Input::Char('v')); editor.handle(Input::Right); editor.handle(Input::Ctrl('e')); assert_eq!(editor.selection_count(), 2); editor.handle(Input::Ctrl('e')); assert_eq!(editor.selection_count(), 3); editor.handle(Input::Left); assert!( editor .selections() .iter() .all(|range| range.start.column == 1 && range.end.column == 2) ); editor.handle(Input::Char('c')); type_text(&mut editor, "!"); editor.handle(Input::Escape); assert_eq!(editor.context().text, "a!c\na!cd\na!cde"); } #[test] fn batch_open_deduplicates_rows_and_join_uses_original_neighbor_rows() { for (key, expected) in [('o', "abc\n\nxyz\n"), ('O', "\nabc\n\nxyz")] { let mut editor = editor(); editor .apply(vec![Effect::Replace("abc\nxyz".into())]) .unwrap(); set_carets(&mut editor, &[(0, 0), (0, 2), (1, 1)], 2); editor.handle(Input::Char(key)); editor.handle(Input::Escape); assert_eq!(editor.context().text, expected); assert_eq!(editor.selection_count(), 2); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "abc\nxyz"); assert_eq!(editor.selection_count(), 3); } let mut editor = editor(); editor .apply(vec![Effect::Replace("a\n b\n c".into())]) .unwrap(); set_carets(&mut editor, &[(0, 0), (1, 1)], 1); editor.handle(Input::Char('J')); assert_eq!(editor.context().text, "a b c"); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "a\n b\n c"); assert_eq!(editor.selection_count(), 2); } #[test] fn plugin_text_effects_and_invocation_are_single_primary_but_messages_are_not() { let mut editor = editor(); editor .apply(vec![Effect::Replace("aa\nbb\ncc".into())]) .unwrap(); editor.register(plugin("sample", &["sample"])).unwrap(); set_carets(&mut editor, &[(0, 0), (1, 0), (2, 0)], 1); assert_eq!(editor.execute("sample").unwrap().command, "sample"); assert_eq!(editor.selection_count(), 3); editor.handle(Input::Char('v')); let ranges = editor.selections(); assert!(editor.apply(vec![Effect::Insert("\0".into())]).is_err()); editor.apply(vec![Effect::Message("ok".into())]).unwrap(); assert_eq!(editor.selections(), ranges); editor.apply(vec![Effect::Insert("!".into())]).unwrap(); assert_eq!(editor.context().text, "aa\n!bb\ncc"); assert_eq!(editor.selection_count(), 1); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "aa\nbb\ncc"); assert_eq!(editor.selection_count(), 3); assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 }); assert_eq!(editor.mode(), Mode::Normal); } #[test] fn literal_paste_broadcasts_and_retains_separate_typing_history_for_all_carets() { let mut editor = editor(); editor.apply(vec![Effect::Replace("a\nb".into())]).unwrap(); editor.handle(Input::Ctrl('e')); editor.handle(Input::Char('i')); type_text(&mut editor, "x"); editor.handle(Input::Paste("界e\u{301}\r\n".into())); type_text(&mut editor, "z"); editor.handle(Input::Escape); assert_eq!(editor.context().text, "x界e\u{301}\nza\nx界e\u{301}\nzb"); for expected in ["x界e\u{301}\na\nx界e\u{301}\nb", "xa\nxb", "a\nb"] { editor.handle(Input::Char('u')); assert_eq!(editor.context().text, expected); assert_eq!(editor.selection_count(), 2); } } #[test] fn caret_motions_and_search_apply_to_all_targets_and_collapse_duplicates() { let mut editor = editor(); editor .apply(vec![Effect::Replace("cat cat\ncat cat".into())]) .unwrap(); set_carets(&mut editor, &[(0, 0), (1, 0), (1, 0)], 2); assert_eq!(editor.selection_count(), 2); assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 }); submit_search(&mut editor, "cat"); assert_eq!( editor.carets(), vec![Cursor { row: 0, column: 4 }, Cursor { row: 1, column: 4 }] ); editor.handle(Input::Char('b')); assert!(editor.carets().iter().all(|cursor| cursor.column == 0)); editor.handle(Input::Up); assert_eq!(editor.carets(), vec![Cursor::default()]); } #[test] fn word_motions_distinguish_punctuation_cross_empty_lines_and_preserve_history() { let mut editor = editor(); let text = "foo_bar...baz qux\n\n \n界e\u{301}_2 👩‍🚀!! tail"; editor.apply(vec![Effect::Replace(text.into())]).unwrap(); for (key, row, column) in [ ('w', 0, 7), ('w', 0, 10), ('w', 0, 15), ('w', 1, 0), ('w', 3, 0), ('w', 3, 5), ('w', 3, 9), ('w', 3, 12), ('w', 3, 12), ('b', 3, 9), ('b', 3, 5), ('b', 3, 0), ('b', 1, 0), ('b', 0, 15), ('b', 0, 10), ('b', 0, 7), ('b', 0, 0), ('b', 0, 0), ('e', 0, 6), ('e', 0, 9), ('e', 0, 12), ('e', 0, 17), ('e', 3, 3), ('e', 3, 7), ('e', 3, 12), ('e', 3, 12), ] { editor.handle(Input::Char(key)); assert_eq!(editor.cursor(), Cursor { row, column }, "{key}"); } assert_eq!(editor.context().text, text); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, ""); editor.handle(Input::Char('U')); assert_eq!(editor.context().text, text); } #[test] fn big_word_motions_use_only_whitespace_and_handle_buffer_edges() { let mut editor = editor(); editor .apply(vec![Effect::Replace( "foo_bar...baz qux\n\n \n界e\u{301}_2 👩‍🚀!! tail".into(), )]) .unwrap(); for (key, row, column) in [ ('W', 0, 15), ('W', 1, 0), ('W', 3, 0), ('W', 3, 5), ('W', 3, 9), ('W', 3, 12), ('B', 3, 9), ('B', 3, 5), ('B', 3, 0), ('B', 1, 0), ('B', 0, 15), ('B', 0, 0), ('B', 0, 0), ('E', 0, 12), ('E', 0, 17), ('E', 3, 3), ('E', 3, 7), ('E', 3, 12), ('E', 3, 12), ] { editor.handle(Input::Char(key)); assert_eq!(editor.cursor(), Cursor { row, column }, "{key}"); } for (text, next, end) in [("", 0, 0), (" ", 2, 2), ("word ", 6, 3)] { editor.apply(vec![Effect::Replace(text.into())]).unwrap(); editor.handle(Input::Home); editor.handle(Input::Char('e')); assert_eq!( editor.cursor(), Cursor { row: 0, column: end } ); editor.handle(Input::Char('w')); assert_eq!( editor.cursor(), Cursor { row: 0, column: next } ); editor.handle(Input::Char('b')); assert_eq!(editor.cursor(), Cursor { row: 0, column: 0 }); } editor .apply(vec![Effect::Replace("one\u{2003}two\u{a0}three\n".into())]) .unwrap(); editor.handle(Input::Home); editor.handle(Input::Char('w')); assert_eq!(editor.cursor(), Cursor { row: 0, column: 4 }); editor.handle(Input::Char('w')); assert_eq!(editor.cursor(), Cursor { row: 0, column: 8 }); editor.handle(Input::Char('w')); assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 }); editor.handle(Input::Char('e')); assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 }); editor.handle(Input::Char('b')); assert_eq!(editor.cursor(), Cursor { row: 0, column: 8 }); } #[test] fn search_wraps_both_ways_and_cancel_failure_and_navigation_preserve_edits() { let mut editor = editor(); let text = "zero cat\ncat cat\nCAT\n"; editor.apply(vec![Effect::Replace(text.into())]).unwrap(); submit_search(&mut editor, "cat"); assert_eq!(editor.cursor(), Cursor { row: 0, column: 5 }); assert_eq!(editor.search_match().unwrap().end_column, 8); editor.handle(Input::Char('n')); assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 }); editor.handle(Input::Char('n')); assert_eq!(editor.cursor(), Cursor { row: 1, column: 4 }); editor.handle(Input::Char('n')); assert_eq!(editor.cursor(), Cursor { row: 0, column: 5 }); editor.handle(Input::Char('N')); assert_eq!(editor.cursor(), Cursor { row: 1, column: 4 }); editor.handle(Input::Char('N')); assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 }); let cursor = editor.cursor(); let active = editor.search_match(); editor.handle(Input::Char('/')); type_text(&mut editor, "other界e\u{301}"); editor.handle(Input::Backspace); assert_eq!(editor.command_line(), "other界"); editor.handle(Input::Paste(":q!\n".into())); assert_eq!(editor.command_line(), "other界"); editor.handle(Input::Escape); assert_eq!(editor.mode(), Mode::Normal); assert_eq!(editor.cursor(), cursor); assert_eq!(editor.search_match(), active); assert!(editor.command_line().is_empty()); submit_search(&mut editor, ""); assert_eq!(editor.cursor(), Cursor { row: 1, column: 4 }); let cursor = editor.cursor(); submit_search(&mut editor, "missing"); assert_eq!(editor.mode(), Mode::Normal); assert_eq!(editor.cursor(), cursor); assert_eq!(editor.search_match(), None); editor.handle(Input::Char('N')); assert_eq!(editor.cursor(), cursor); assert!(!editor.should_quit()); assert_eq!(editor.context().text, text); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, ""); editor.handle(Input::Char('U')); assert_eq!(editor.context().text, text); } #[test] fn search_is_literal_overlapping_and_grapheme_safe_and_rechecks_after_edits() { let mut editor = editor(); editor .apply(vec![Effect::Replace("e\u{301} e 👩‍🚀 👩 aa... aaa".into())]) .unwrap(); submit_search(&mut editor, "e"); assert_eq!(editor.cursor().column, 2); submit_search(&mut editor, "\u{301}"); assert_eq!(editor.cursor().column, 2); assert_eq!(editor.search_match(), None); submit_search(&mut editor, "👩"); assert_eq!(editor.cursor().column, 6); submit_search(&mut editor, "👩‍🚀"); assert_eq!(editor.cursor().column, 4); assert_eq!(editor.search_match().unwrap().end_column, 5); submit_search(&mut editor, "."); assert_eq!(editor.cursor().column, 10); editor.handle(Input::Char('n')); assert_eq!(editor.cursor().column, 11); submit_search(&mut editor, "aa"); assert_eq!(editor.cursor().column, 14); editor.handle(Input::Char('n')); assert_eq!(editor.cursor().column, 15); editor.handle(Input::Char('n')); assert_eq!(editor.cursor().column, 8); submit_search(&mut editor, "e\u{301}"); assert_eq!(editor.cursor().column, 0); assert_eq!(editor.search_match().unwrap().end_column, 1); let active = editor.search_match(); assert!(editor.apply(vec![Effect::Replace("\0".into())]).is_err()); assert_eq!(editor.search_match(), active); editor.handle(Input::Char('x')); assert_eq!(editor.search_match(), None); editor.handle(Input::Char('n')); assert_eq!(editor.search_match(), None); editor.handle(Input::Char('u')); assert_eq!(editor.search_match(), None); editor.handle(Input::Char('n')); assert_eq!(editor.cursor().column, 0); assert_eq!(editor.search_match().unwrap().end_column, 1); editor .apply(vec![Effect::Replace("e\u{301}".into())]) .unwrap(); assert_eq!(editor.search_match(), None); editor.handle(Input::Char('n')); assert_eq!(editor.cursor().column, 0); assert_eq!(editor.search_match().unwrap().end_column, 1); } #[test] fn join_lines_matches_vim_spacing_and_grapheme_cursor_positions() { for (original, expected, column) in [ ("alpha\n \tbeta\ntail", "alpha beta\ntail", 5), ("alpha \n\tbeta", "alpha beta", 7), ("alpha\t\n beta", "alpha\tbeta", 6), ("\n beta", "beta", 0), ("alpha\n \t", "alpha", 4), ("\n", "", 0), ("alpha\n )tail", "alpha)tail", 5), ("Sentence.\n next", "Sentence. next", 9), ("界e\u{301}\n next", "界e\u{301} next", 2), ("x \n\u{301}y", "x \u{301}y", 1), ] { let mut editor = editor(); editor .apply(vec![Effect::Replace(original.into())]) .unwrap(); editor.handle(Input::Char('J')); assert_eq!(editor.context().text, expected); assert_eq!(editor.cursor(), Cursor { row: 0, column }); assert_eq!(editor.mode(), Mode::Normal); } } #[test] fn join_is_one_undo_action_on_the_current_row_and_restores_cursor() { let mut editor = editor(); let original = "above\nalpha\n beta\nbelow"; editor .apply(vec![Effect::Replace(original.into())]) .unwrap(); editor.handle(Input::Down); editor.handle(Input::Right); let before = editor.cursor(); editor.handle(Input::Char('J')); assert_eq!(editor.context().text, "above\nalpha beta\nbelow"); assert_eq!(editor.cursor(), Cursor { row: 1, column: 5 }); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, original); assert_eq!(editor.cursor(), before); editor.handle(Input::Char('U')); assert_eq!(editor.context().text, "above\nalpha beta\nbelow"); editor.handle(Input::Char('x')); assert_eq!(editor.context().text, "above\nalphabeta\nbelow"); } #[test] fn join_at_eof_preserves_history_and_insert_mode_j_is_literal() { let mut editor = editor(); editor .apply(vec![Effect::Replace("single".into())]) .unwrap(); editor.handle(Input::Char('u')); editor.handle(Input::Char('J')); assert_eq!(editor.context().text, ""); editor.handle(Input::Char('U')); assert_eq!(editor.context().text, "single"); editor.handle(Input::Char('J')); assert_eq!(editor.context().text, "single"); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, ""); editor.handle(Input::Char('U')); editor.handle(Input::Char('i')); editor.handle(Input::Char('J')); editor.handle(Input::Escape); assert_eq!(editor.context().text, "Jsingle"); } #[test] fn uppercase_u_redoes_and_control_r_no_longer_does() { let mut editor = editor(); editor.handle(Input::Char('i')); type_text(&mut editor, "changed"); editor.handle(Input::Escape); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, ""); editor.handle(Input::Ctrl('r')); assert_eq!(editor.context().text, ""); editor.handle(Input::Char('U')); assert_eq!(editor.context().text, "changed"); } #[test] fn uppercase_insert_keys_target_line_positions_and_preserve_undo_cursor() { for (original, key, expected) in [ (" \t界e\u{301} tail", 'I', " \tX界e\u{301} tail"), (" \t ", 'I', " \t X"), ("", 'I', "X"), (" \t界e\u{301} tail", 'A', " \t界e\u{301} tailX"), ("", 'A', "X"), ] { let mut editor = editor(); editor .apply(vec![Effect::Replace(original.into())]) .unwrap(); editor.handle(if key == 'I' { Input::End } else { Input::Right }); let before = editor.cursor(); editor.handle(Input::Char(key)); assert_eq!(editor.mode(), Mode::Insert); type_text(&mut editor, "X"); editor.handle(Input::Escape); assert_eq!(editor.context().text, expected); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, original); assert_eq!(editor.cursor(), before); editor.handle(Input::Char('U')); assert_eq!(editor.context().text, expected); } } #[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::Char('U')); 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::Char('U')); assert_eq!(editor.context().text, "beforeafter"); } } #[test] fn insert_sessions_and_open_lines_are_single_undo_actions() { let mut editor = editor(); editor.handle(Input::Char('i')); type_text(&mut editor, "hello\n世界"); editor.handle(Input::Escape); assert_eq!(editor.context().text, "hello\n世界"); assert!(editor.dirty()); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, ""); assert!(!editor.dirty()); editor.handle(Input::Char('U')); assert_eq!(editor.context().text, "hello\n世界"); editor.handle(Input::Char('O')); type_text(&mut editor, "above"); editor.handle(Input::Escape); assert_eq!(editor.context().text, "hello\nabove\n世界"); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "hello\n世界"); editor.handle(Input::Char('o')); type_text(&mut editor, "below"); editor.handle(Input::Escape); assert_eq!(editor.context().text, "hello\n世界\nbelow"); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "hello\n世界"); } #[test] fn grapheme_navigation_and_deletion_do_not_split_unicode() { let mut editor = editor(); editor .apply(vec![Effect::Replace("a\u{301}👩‍💻界".into())]) .unwrap(); editor.handle(Input::Char('l')); assert_eq!(editor.cursor().column, 1); editor.handle(Input::Char('x')); assert_eq!(editor.context().text, "a\u{301}界"); editor.handle(Input::Char('0')); editor.handle(Input::Delete); assert_eq!(editor.context().text, "界"); editor.handle(Input::Char('a')); type_text(&mut editor, "👨‍👩‍👧"); editor.handle(Input::Backspace); assert_eq!(editor.context().text, "界"); editor.handle(Input::Escape); assert_eq!(editor.cursor().column, 0); assert_eq!(editor.context().column, 1); } #[test] fn insertion_merges_combining_graphemes_and_joins_lines() { let mut editor = editor(); editor.handle(Input::Char('i')); type_text(&mut editor, "a\u{301}\nb"); editor.handle(Input::Home); editor.handle(Input::Backspace); assert_eq!(editor.context().text, "a\u{301}b"); assert_eq!(editor.cursor(), Cursor { row: 0, column: 1 }); editor.handle(Input::Enter); editor.handle(Input::Up); editor.handle(Input::End); editor.handle(Input::Delete); assert_eq!(editor.context().text, "a\u{301}b"); editor.handle(Input::Escape); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, ""); } #[test] fn cursor_is_clamped_on_short_and_empty_lines() { let mut editor = editor(); editor .apply(vec![Effect::Replace("long\nx\n".into())]) .unwrap(); editor.handle(Input::End); assert_eq!(editor.cursor().column, 3); editor.handle(Input::Down); assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 }); editor.handle(Input::Char('j')); editor.handle(Input::Char('l')); editor.handle(Input::Char('x')); assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 }); assert_eq!(editor.context().row, 3); editor.handle(Input::Char('i')); editor.handle(Input::Left); editor.handle(Input::Delete); editor.handle(Input::Escape); assert_eq!(editor.context().text, "long\nx\n"); } #[test] fn registry_rejects_incompatible_duplicate_and_forged_core_atomically() { let mut editor = editor(); let mut feature = plugin("graphics", &["draw"]); feature.required_capabilities.push("graphics".into()); assert!(editor.register(feature).is_err()); assert!(editor.bind("normal", "g", Some("draw".into())).is_err()); assert!( editor .register(plugin("duplicate", &["newcmd", "w"])) .is_err() ); assert!(editor.bind("normal", "g", Some("newcmd".into())).is_err()); assert!( editor .register(plugin("repeat", &["same", "same"])) .is_err() ); assert!(editor.register(plugin("invalid", &["bad name"])).is_err()); assert!(editor.register(plugin("", &["emptyname"])).is_err()); let mut feature = plugin("forged", &["override"]); feature.core = true; assert!(editor.register(feature).is_err()); editor.register(plugin("working", &["greet"])).unwrap(); assert!(editor.register(plugin("working", &["different"])).is_err()); assert!(editor.register(plugin("other", &["greet"])).is_err()); assert!(editor.register(plugin("collision", &["plugins"])).is_err()); editor.disable_plugin("core", "attempt"); editor.execute("q"); assert!(editor.should_quit()); } #[test] fn readable_key_aliases_share_bindings_and_invalid_chords_leave_them_intact() { let mut editor = editor(); editor.bind("normal", "shift+i", None).unwrap(); editor.handle(Input::Char('I')); assert_eq!(editor.mode(), Mode::Normal); editor .bind("normal", "I", Some("core.insert".into())) .unwrap(); editor.handle(Input::Char('I')); assert_eq!(editor.mode(), Mode::Insert); editor .bind("insert", "plus", Some("core.newline".into())) .unwrap(); editor.bind("insert", "+", None).unwrap(); editor.handle(Input::Char('+')); type_text(&mut editor, "界"); assert_eq!(editor.lines(), &["界"]); editor .bind("insert", "plus", Some("core.newline".into())) .unwrap(); editor.handle(Input::Char('+')); assert_eq!(editor.lines(), &["界", ""]); editor.bind("insert", "CTRL+I", None).unwrap(); editor.handle(Input::Char('\t')); assert_eq!(editor.lines(), &["界", ""]); editor .bind("insert", "TAB", Some("core.newline".into())) .unwrap(); editor.handle(Input::Ctrl('i')); assert_eq!(editor.lines(), &["界", "", ""]); editor .bind("insert", "shift+j", Some("core.normal".into())) .unwrap(); for key in [ "", "", "", "ctrl+", "+r", "ctrl++r", "ctrl+shift+j", "shift+tab", "shift+1", "alt+j", "cmd+j", "ctrl+界", ] { assert!( editor .bind("insert", key, Some("core.newline".into())) .is_err(), "{key}" ); } editor.handle(Input::Char('J')); assert_eq!(editor.mode(), Mode::Normal); editor.handle(Input::Char('u')); assert_eq!(editor.lines(), &[String::new()]); } #[test] fn plugin_bindings_dispatch_only_normal_mode_and_retire_with_owner() { let mut editor = editor(); editor.register(plugin("greeting", &["greet"])).unwrap(); assert!(editor.bind("insert", "g", Some("greet".into())).is_err()); editor.bind("normal", "g", Some("greet".into())).unwrap(); assert_eq!( editor.handle(Input::Char('g')), Some(Invocation { plugin: "greeting".into(), command: "greet".into(), args: String::new(), }) ); editor.handle(Input::Char('i')); assert_eq!(editor.handle(Input::Char('g')), None); assert_eq!(editor.context().text, "g"); editor.handle(Input::Escape); editor.disable_plugin("greeting", "crashed\u{1b}[31m"); assert_eq!(editor.handle(Input::Char('g')), None); assert!(editor.bind("normal", "g", Some("greet".into())).is_err()); assert!(!editor.message().contains('\u{1b}')); } #[test] fn command_mode_dispatches_arguments_and_backspaces_whole_graphemes() { let mut editor = editor(); editor.register(plugin("greeting", &["greet"])).unwrap(); editor.handle(Input::Char(':')); type_text(&mut editor, "greet a\u{301}"); editor.handle(Input::Backspace); assert_eq!(editor.command_line(), "greet "); type_text(&mut editor, "world"); assert_eq!( editor.handle(Input::Enter), Some(Invocation { plugin: "greeting".into(), command: "greet".into(), args: "world".into(), }) ); assert_eq!(editor.mode(), Mode::Normal); assert_eq!(editor.command_line(), ""); editor.handle(Input::Char(':')); type_text(&mut editor, "q!"); editor.handle(Input::Escape); assert!(!editor.should_quit()); assert_eq!(editor.command_line(), ""); } #[test] fn plugin_effects_validate_before_any_mutation_and_group_undo() { let mut editor = editor(); editor.set_message("unchanged"); assert!( editor .apply(vec![ Effect::Insert("good".into()), Effect::Message("bad\u{1b}".into()) ]) .is_err() ); assert_eq!(editor.context().text, ""); assert_eq!(editor.message(), "unchanged"); assert!(!editor.dirty()); assert!( editor .apply(vec![ Effect::Insert("a".repeat(MAX_EDIT_BYTES)), Effect::Insert("x".into()) ]) .is_err() ); assert_eq!(editor.context().text, ""); editor .apply(vec![ Effect::Insert("one".into()), Effect::Insert("\ntwo".into()), Effect::Message("ok".into()), ]) .unwrap(); assert_eq!(editor.context().text, "one\ntwo"); assert_eq!(editor.message(), "ok"); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, ""); editor.handle(Input::Char('U')); assert_eq!(editor.context().text, "one\ntwo"); editor .apply(vec![Effect::Replace("replacement".into())]) .unwrap(); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "one\ntwo"); } #[test] fn plugin_effect_transactions_preserve_insert_sessions() { let mut editor = editor(); editor.handle(Input::Char('i')); type_text(&mut editor, "before"); let cursor = editor.cursor(); assert!( editor .apply(vec![Effect::Replace("invalid\0".into())]) .is_err() ); assert_eq!(editor.mode(), Mode::Insert); assert_eq!(editor.cursor(), cursor); type_text(&mut editor, "after"); editor.handle(Input::Escape); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, ""); editor.handle(Input::Char('i')); type_text(&mut editor, "a"); editor .apply(vec![ Effect::Insert("b".into()), Effect::Message("plugin".into()), ]) .unwrap(); type_text(&mut editor, "c"); editor.handle(Input::Escape); assert_eq!(editor.context().text, "abc"); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "ab"); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "a"); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, ""); } #[test] fn no_op_edits_preserve_redo_but_new_edits_clear_it() { let mut editor = editor(); editor.apply(vec![Effect::Insert("a".into())]).unwrap(); editor.handle(Input::Char('u')); editor.handle(Input::Char('i')); editor.handle(Input::Escape); editor .apply(vec![Effect::Message("notice".into())]) .unwrap(); editor.handle(Input::Char('U')); assert_eq!(editor.context().text, "a"); editor.handle(Input::Char('u')); editor.apply(vec![Effect::Insert("b".into())]).unwrap(); editor.handle(Input::Char('U')); assert_eq!(editor.context().text, "b"); } #[test] fn saves_preserve_empty_files_lf_trailing_newlines_and_dirty_history() { let directory = Directory::new(); for (name, text) in [ ("empty", ""), ("no-newline", "hello"), ("newline", "hello\n"), ("two-newlines", "hello\n\n"), ] { let path = directory.path(name); fs::write(&path, text).unwrap(); let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); editor.set_clipboard_mode("separate").unwrap(); editor.execute("w"); assert_eq!(fs::read_to_string(&path).unwrap(), text); assert!(!editor.dirty()); } let path = directory.path("new file"); let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); editor.set_clipboard_mode("separate").unwrap(); editor.handle(Input::Char('i')); type_text(&mut editor, "saved\n"); editor.handle(Input::Escape); editor.execute("q"); assert!(!editor.should_quit()); editor.execute("w"); assert_eq!(fs::read_to_string(&path).unwrap(), "saved\n"); assert!(!editor.dirty()); editor.handle(Input::Char('u')); assert!(editor.dirty()); editor.handle(Input::Char('U')); assert!(!editor.dirty()); editor.execute("q"); assert!(editor.should_quit()); } #[test] fn save_as_and_failed_wq_keep_path_dirty_state_and_quit_correct() { let directory = Directory::new(); let mut editor = editor(); editor .apply(vec![Effect::Insert("unsaved".into())]) .unwrap(); editor.execute("w"); assert!(editor.dirty()); let bad = directory.path("absent/file"); editor.execute(&format!("wq {}", bad.display())); assert!(editor.dirty()); assert!(!editor.should_quit()); assert_eq!(editor.path(), None); let path = directory.path("save as.txt"); editor.execute(&format!("w {}", path.display())); assert_eq!(editor.path(), Some(path.as_path())); assert!(!editor.dirty()); editor.apply(vec![Effect::Insert("new".into())]).unwrap(); editor.execute("q!"); assert!(editor.should_quit()); assert_eq!(fs::read_to_string(path).unwrap(), "unsaved"); } #[test] fn loaded_controls_are_rejected_and_cr_line_endings_are_normalized() { let directory = Directory::new(); let path = directory.path("control"); for text in ["nul\0", "escape\u{1b}[31m", "backspace\u{8}", "c1\u{85}"] { fs::write(&path, text).unwrap(); assert!( matches!(Editor::open(Some(path.clone()), vec![]), Err(error) if error.kind() == io::ErrorKind::InvalidData) ); } fs::write(&path, "a\t界\r\nb\rc\n").unwrap(); let editor = Editor::open(Some(path), vec![]).unwrap(); assert_eq!(editor.context().text, "a\t界\nb\nc\n"); assert!(!editor.dirty()); assert!(Editor::open(Some(directory.0.clone()), vec![]).is_err()); } #[cfg(unix)] #[test] fn saves_preserve_permissions_and_reject_symlinks_without_replacing_them() { use std::os::unix::fs::{PermissionsExt, symlink}; let directory = Directory::new(); let path = directory.path("original"); fs::write(&path, "original").unwrap(); fs::set_permissions(&path, fs::Permissions::from_mode(0o640)).unwrap(); let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); editor.set_clipboard_mode("separate").unwrap(); editor .apply(vec![Effect::Replace("changed".into())]) .unwrap(); editor.execute("w"); assert_eq!( fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o640 ); let link = directory.path("link"); symlink(&path, &link).unwrap(); let mut editor = Editor::open(Some(link.clone()), vec![]).unwrap(); editor.set_clipboard_mode("separate").unwrap(); editor .apply(vec![Effect::Replace("not saved".into())]) .unwrap(); editor.execute("wq"); assert!(editor.dirty()); assert!(!editor.should_quit()); assert!( fs::symlink_metadata(&link) .unwrap() .file_type() .is_symlink() ); assert_eq!(fs::read_to_string(&path).unwrap(), "changed"); assert_eq!(fs::read_dir(&directory.0).unwrap().count(), 2); } fn annotation(start: (usize, usize), end: (usize, usize), message: &str) -> Diagnostic { Diagnostic { start: TextPosition { row: start.0, column: start.1, }, end: TextPosition { row: end.0, column: end.1, }, severity: crate::plugin::DiagnosticSeverity::Warning, message: message.into(), } } #[test] fn annotations_are_owner_scoped_atomic_and_grapheme_bounded() { let mut editor = editor(); editor .apply(vec![Effect::Replace("a😀e\u{301}\n".into())]) .unwrap(); let id = editor.buffer_id(); let revision = editor.text_revision(); let first = annotation((0, 1), (0, 3), "first"); editor .replace_diagnostics("one", id, revision, vec![first.clone()]) .unwrap(); editor .replace_diagnostics("two", id, revision, vec![annotation((1, 0), (1, 0), "end")]) .unwrap(); for invalid in [ annotation((0, 0), (0, 4), "scalar column"), annotation((0, 3), (0, 2), "reversed"), annotation((2, 0), (2, 0), "missing row"), annotation((0, 0), (0, 1), "control\u{1b}"), ] { assert!( editor .replace_diagnostics("one", id, revision, vec![first.clone(), invalid]) .is_err() ); assert_eq!(editor.diagnostics().count(), 2); assert_eq!( editor.active.diagnostics["one"].items.as_slice(), std::slice::from_ref(&first) ); } assert!( !editor .replace_diagnostics( "one", id, revision - 1, vec![annotation((99, 0), (99, 0), "stale")] ) .unwrap() ); assert!( !editor .replace_diagnostics("one", 999, revision, vec![first.clone()]) .unwrap() ); editor.active.targets = vec![Target::caret(Cursor { row: 0, column: 1 })]; assert_eq!(editor.diagnostic_at_cursor().unwrap().message, "first"); editor.active.targets = vec![Target::caret(Cursor { row: 1, column: 0 })]; assert_eq!(editor.diagnostic_at_cursor().unwrap().message, "end"); editor.clear_plugin_diagnostics("one"); assert_eq!(editor.diagnostics().count(), 1); } #[test] fn annotations_do_not_split_typing_undo_and_clear_on_text_change() { let mut editor = editor(); editor.handle(Input::Char('i')); editor.handle(Input::Char('a')); let revision = editor.text_revision(); editor .replace_diagnostics( "one", editor.buffer_id(), revision, vec![annotation((0, 0), (0, 1), "typed")], ) .unwrap(); assert!(editor.active.insert_start.is_some()); assert!(editor.active.undo.is_empty()); editor.handle(Input::Char('b')); assert_eq!(editor.diagnostics().count(), 0); editor.handle(Input::Escape); editor.handle(Input::Char('u')); assert_eq!(editor.lines(), [""]); assert!(editor.active.undo.is_empty()); } #[test] fn navigation_reuses_dirty_hidden_text_and_failure_preserves_source() { let directory = Directory::new(); let source = directory.path("source"); let target = directory.path("target"); fs::write(&source, "source").unwrap(); fs::write(&target, "a😀e\u{301}\n").unwrap(); let mut editor = Editor::open(Some(source.clone()), vec!["text".into()]).unwrap(); editor.set_clipboard_mode("separate").unwrap(); editor.execute(&format!("edit {}", target.display())); editor .apply(vec![Effect::Replace("dirty😀e\u{301}\n".into())]) .unwrap(); let target_id = editor.buffer_id(); editor.execute("buffer 1"); editor.handle(Input::Char('i')); editor.handle(Input::Char('!')); let before = editor.context().text; let cursor = editor.cursor(); let undo = editor.active.undo.len(); assert!( editor .navigate_to( directory.path("missing"), TextPosition { row: 0, column: 0 } ) .is_err() ); assert_eq!(editor.buffer_id(), 1); assert_eq!(editor.context().text, before); assert_eq!(editor.cursor(), cursor); assert_eq!(editor.mode(), Mode::Insert); assert_eq!(editor.active.undo.len(), undo); assert!(editor.active.insert_start.is_some()); editor .navigate_to( target, TextPosition { row: 99, column: 99, }, ) .unwrap(); assert_eq!(editor.buffer_id(), target_id); assert_eq!(editor.lines(), ["dirty😀e\u{301}", ""]); assert_eq!(editor.buffer_count(), 2); assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 }); assert_eq!(editor.mode(), Mode::Normal); assert_eq!(fs::read_to_string(source).unwrap(), "source"); } }