use crate::clipboard::{ClipboardMode, SystemClipboard}; use crate::files::{DockSide, FilePicker, FilePresentation, FileView}; use crate::plugin::{CompletionItem, Diagnostic, SemanticToken, SnippetTabstop, TextPosition}; use serde::Serialize; use std::borrow::Cow; use std::collections::{BTreeMap, HashMap, HashSet, VecDeque}; use std::fmt; use std::fs::{self, OpenOptions}; use std::io::{self, Write}; use std::ops::Range; #[cfg(unix)] use std::os::unix::fs::OpenOptionsExt; use std::path::{Component, Path, PathBuf}; use std::sync::Arc; 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, BackTab, 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 StartupDiagnostic { pub lines: Vec, pub top: usize, pub left: usize, max_column: usize, } pub struct Hover { text: String, line_starts: Vec, /// First visible logical line. pub top: usize, /// First visible extended grapheme, independent of terminal geometry. pub left: usize, max_column: usize, buffer_id: usize, revision: u64, caret: Cursor, } /// A live preview; zero selects the immutable original document. pub struct Completion { pub items: Vec, pub selected: usize, } struct CompletionState { popup: Completion, original: Snapshot, } struct SnippetState { tabstops: Vec, active: usize, selected: bool, selections: Vec, replacement: Range, recipe_step: Option, } #[cfg_attr(test, derive(Debug))] struct RelativeCompletionEdit { start: isize, end: isize, text: String, // Extra edits are replayed only against the same surrounding logical lines. context: Option>, } impl Hover { pub fn text(&self) -> &str { &self.text } pub fn lines(&self) -> impl ExactSizeIterator { self.line_starts.iter().enumerate().map(|(index, &start)| { let end = self .line_starts .get(index + 1) .map_or(self.text.len(), |next| next - 1); &self.text[start..end] }) } } 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: Option, 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, }, Lines { change: bool, count: usize, }, Delete, Join, Paste { after: bool, fragments: Vec, linewise: bool, }, } #[cfg_attr(test, derive(Debug))] enum SelectionIntent { Motions(Vec<(Action, usize)>), // Whole graphemes, with a newline counting as one boundary. Spans(Vec), Rows(Vec), } #[cfg_attr(test, derive(Debug))] enum InsertStep { Typed(String), Bulk(String), Move(Action, usize), Delete(Vec), Backspace(Vec), Completion { primary_edit: usize, edits: Vec, caret: usize, }, } #[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, DocumentStart, DocumentEnd, Delete, Undo, Redo, RepeatLastEdit, Command, Normal, Newline, Backspace, DeleteLine, ChangeLine, Submit, Search, SearchNext, SearchPrevious, WordNext, WordPrevious, WordEnd, BigWordNext, BigWordPrevious, BigWordEnd, Select, SelectLinewise, Copy, Cut, CopySystem, Change, PasteBefore, PasteAfter, PasteSystemBefore, PasteSystemAfter, SelectLines, CloneAbove, CloneBelow, SelectNext, SkipOccurrence, SelectAll, Palette, FilePicker, FileExplorer, FollowLink, JumpBack, JumpForward, LspDefinition, LspHover, LspDiagnostics, LspCompletion, SnippetNext, SnippetPrevious, } // 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_linewise", Action::SelectLinewise, &[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.delete_line", Action::DeleteLine, &[Mode::Normal]), ("core.change_line", Action::ChangeLine, &[Mode::Normal]), ("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.document_start", Action::DocumentStart, &[Mode::Normal, Mode::Visual], ), ( "core.document_end", Action::DocumentEnd, &[Mode::Normal, 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], ), ("core.follow_link", Action::FollowLink, &[Mode::Normal]), ("core.jump_back", Action::JumpBack, &[Mode::Normal]), ("core.jump_forward", Action::JumpForward, &[Mode::Normal]), ( "core.lsp.definition", Action::LspDefinition, &[Mode::Normal], ), ("core.lsp.hover", Action::LspHover, &[Mode::Normal]), ( "core.lsp.completion", Action::LspCompletion, &[Mode::Insert], ), ("core.snippet.next", Action::SnippetNext, &[Mode::Insert]), ( "core.snippet.previous", Action::SnippetPrevious, &[Mode::Insert], ), ( "core.lsp.diagnostics", Action::LspDiagnostics, &[Mode::Normal], ), ]; /// Serializes the authoritative action names without copying the catalog. pub(crate) struct CoreActionCatalog; impl Serialize for CoreActionCatalog { fn serialize(&self, serializer: S) -> Result { serializer.collect_seq(CORE_ACTIONS.iter().map(|(name, _, _)| *name)) } } 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('\t')) if modifier.eq_ignore_ascii_case("shift") => { Ok(Input::BackTab) } (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::BackTab => display.push_str("Shift+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 (key, action) in [ (Input::Ctrl('o'), JumpBack), (Input::Char('\t'), JumpForward), ] { bindings .entry(Mode::Normal) .or_default() .insert(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), ('V', SelectLinewise), ('.', 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)); } bindings .entry(Mode::Insert) .or_default() .insert(Input::Ctrl('n'), Binding::Action(LspCompletion)); for (key, action) in [ (Input::Char('\t'), SnippetNext), (Input::BackTab, SnippetPrevious), ] { bindings .entry(Mode::Insert) .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, semantic_tokens: Option>, targets: Vec, primary: usize, mode: Mode, linewise: bool, 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(), semantic_tokens: None, targets: vec![Target::caret(Cursor::default())], primary: 0, mode: Mode::Normal, linewise: false, 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(); self.semantic_tokens = None; } } #[derive(Clone, Copy, Debug, Eq, PartialEq)] struct JumpLocation { buffer_id: usize, cursor: Cursor, } pub struct Editor { active: Buffer, parked: BTreeMap, next_buffer_id: usize, jumps: VecDeque, jump_index: usize, caret_reveal_revision: u64, command_line: String, clipboard: Vec, clipboard_linewise: bool, clipboard_mode: ClipboardMode, system_clipboard: SystemClipboard, // Exact exported bytes bridge our fragments; external newlines never imply boundaries. owned_system_clipboard: Option<(String, Vec, bool)>, 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, g_pending: bool, line_pending: Option, palette: Option, hover: Option, completion: Option, snippet: Option, startup_diagnostic: 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, jumps: VecDeque::new(), jump_index: 0, caret_reveal_revision: 0, command_line: String::new(), clipboard: Vec::new(), clipboard_linewise: false, 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(), g_pending: false, line_pending: None, palette: None, hover: None, completion: None, snippet: None, startup_diagnostic: 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!", "follow_link", "lsp.definition", "lsp.hover", "lsp.diagnostics", ] .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 semantic_tokens(&self) -> &[SemanticToken] { self.active.semantic_tokens.as_deref().unwrap_or_default() } pub(crate) fn validate_semantic_tokens( &self, buffer_id: usize, revision: u64, tokens: Option<&[SemanticToken]>, ) -> Result { let Some(buffer) = self.buffer(buffer_id) else { return Ok(false); }; if buffer.text_revision != revision { return Ok(false); } let Some(tokens) = tokens else { return Ok(true); }; if tokens.len() > MAX_EDIT_BYTES / std::mem::size_of::() { return Err("Semantic tokens exceed 4 MiB".into()); } let mut previous = None; let mut row_length = None; for token in tokens { if previous.is_some_and(|start| token.start < start) { return Err("Semantic token starts must be sorted".into()); } if token.start.row != token.end.row || token.start >= token.end { return Err("Semantic tokens must have nonempty single-line ranges".into()); } if token.modifiers & !1023 != 0 { return Err("Semantic token modifiers are not canonical".into()); } if row_length.is_none_or(|(row, _)| row != token.start.row) { let line = buffer .lines .get(token.start.row) .ok_or("Semantic token position is outside the document")?; row_length = Some((token.start.row, grapheme_count(line))); } if token.end.column > row_length.unwrap().1 { return Err("Semantic token position is outside the document".into()); } previous = Some(token.start); } Ok(true) } pub fn replace_semantic_tokens( &mut self, buffer_id: usize, revision: u64, tokens: Option>, ) -> Result { if !self.validate_semantic_tokens(buffer_id, revision, tokens.as_deref())? { return Ok(false); } Ok(self.replace_validated_semantic_tokens(buffer_id, revision, tokens)) } /// Only session's fully prevalidated batch may use this storage path. pub(crate) fn replace_validated_semantic_tokens( &mut self, buffer_id: usize, revision: u64, tokens: Option>, ) -> 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; } let tokens = tokens.filter(|tokens| !tokens.is_empty()); match (&buffer.semantic_tokens, &tokens) { (None, None) => return false, (Some(current), Some(next)) if Arc::ptr_eq(current, next) => return false, _ => {} } buffer.semantic_tokens = tokens; true } pub fn clear_semantic_tokens(&mut self) { self.active.semantic_tokens = None; for buffer in self.parked.values_mut() { buffer.semantic_tokens = None; } } 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> { self.finish_completion(); self.snippet = None; let source = self.jump_location(); self.cancel_leader(); self.clear_hover(); 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.place_navigation(position); self.record_jump(source); Ok(()) } fn place_navigation(&mut self, position: TextPosition) { self.reveal_caret(); 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(); } fn follow_link(&mut self) -> Result<(), String> { let departure = self.jump_location(); if self .active .path .as_deref() .is_some_and(|path| !crate::syntax::is_markdown_path(path)) { return Err("follow_link requires a Markdown document".into()); } let source = self.active.lines.join("\n"); let cursor = self.cursor(); let target = crate::markdown::link_at( &source, TextPosition { row: cursor.row, column: cursor.column, }, )?; let Some(path) = target.path else { let row = match target.fragment { Some(fragment) => crate::markdown::heading_row(&source, &fragment)?, None => 0, }; self.place_navigation(TextPosition { row, column: 0 }); self.record_jump(departure); return Ok(()); }; let path = if path.is_absolute() { path } else { let source_path = self .active .identity .as_deref() .ok_or("Relative links require a named Markdown document")?; source_path .parent() .ok_or("Markdown document has no parent directory")? .join(path) }; if path.to_string_lossy().chars().any(char::is_control) { return Err("Link path cannot contain control characters".into()); } let identity = path_identity(&path).map_err(|error| error.to_string())?; let heading_row = |buffer: &Buffer| { target.fragment.as_deref().map_or(Ok(0), |fragment| { crate::markdown::heading_row(&buffer.lines.join("\n"), fragment) }) }; let loaded = std::iter::once(&self.active) .chain(self.parked.values()) .find(|buffer| buffer.identity.as_ref() == Some(&identity)); let row = if let Some(buffer) = loaded { let row = heading_row(buffer)?; let id = buffer.id; self.switch_buffer(id); row } else { let buffer = Buffer::open(self.next_buffer_id, Some(path), Some(identity), false) .map_err(|error| format!("Cannot open link target: {error}"))?; let row = heading_row(&buffer)?; self.add_buffer(buffer); row }; self.place_navigation(TextPosition { row, column: 0 }); self.record_jump(departure); 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 } /// Navigation requests caret following even when its logical position stays unchanged. pub(crate) fn caret_reveal_revision(&self) -> u64 { self.caret_reveal_revision } fn reveal_caret(&mut self) { self.caret_reveal_revision = self.caret_reveal_revision.wrapping_add(1); } 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 startup_diagnostic(&self) -> Option<&StartupDiagnostic> { self.startup_diagnostic.as_ref() } pub(crate) fn show_startup_diagnostic(&mut self, text: String) { self.finish_completion(); self.snippet = None; let lines = split_lines(&text); let max_column = lines .iter() .map(|line| line.graphemes(true).count()) .max() .unwrap_or(0); self.startup_diagnostic = Some(StartupDiagnostic { lines, top: 0, left: 0, max_column, }); } /// Borrowed live-completion state; original document bookkeeping stays private. pub fn completion(&self) -> Option<&Completion> { self.completion.as_ref().map(|state| &state.popup) } /// End-exclusive ranges of the active argument, including its mirrored occurrences. pub fn snippet_selections(&self) -> &[Selection] { self.snippet .as_ref() .map_or(&[], |state| state.selections.as_slice()) } /// Validates the entire response before changing text, history, or popup state. pub fn validate_completion(&self, items: &[CompletionItem]) -> Result<(), String> { validate_completion_items(&self.active.lines, items) } pub fn show_completion(&mut self, mut items: Vec) -> Result<(), String> { if self.mode() != Mode::Insert || self.palette.is_some() || self.startup_diagnostic.is_some() || self .file_picker .as_ref() .is_some_and(|picker| picker.focused) { return Err("Completion requires Insert editor focus".into()); } self.validate_completion(&items)?; self.finish_completion(); self.snippet = None; if items.is_empty() { self.set_message("No completions"); return Ok(()); } for item in &mut items { for edit in &mut item.edits { if let Cow::Owned(normalized) = normalize_newlines(&edit.text) { edit.text = normalized; } } } let original = self.snapshot(); self.preview_completion(&original, &items[0]); self.completion = Some(CompletionState { popup: Completion { items, selected: 1 }, original, }); self.message.clear(); Ok(()) } fn preview_completion(&mut self, original: &Snapshot, item: &CompletionItem) { let offsets = line_offsets(&original.lines); let mut edits = completion_edits(&original.lines, &offsets, original.primary, item); let primary_start = edits[item.primary_edit].start; edits.sort_by_key(|edit| (edit.start, edit.end)); let start = translate_edit_offset(primary_start, false, edits.iter()); self.snippet = item.snippet.as_ref().map(|snippet| SnippetState { tabstops: snippet .tabstops .iter() .map(|stop| SnippetTabstop { index: stop.index, ancestors: stop.ancestors.clone(), ranges: stop .ranges .iter() .map(|range| start + range.start..start + range.end) .collect(), }) .collect(), active: 0, selected: true, selections: Vec::new(), replacement: start..start + item.edits[item.primary_edit].text.len(), recipe_step: None, }); self.apply_edits_from(edits, Some(original), true); self.select_snippet_stop(); } fn cycle_completion(&mut self, next: bool) { let mut state = self.completion.take().expect("completion is active"); let count = state.popup.items.len() + 1; state.popup.selected = if next { (state.popup.selected + 1) % count } else { (state.popup.selected + count - 1) % count }; if state.popup.selected == 0 { self.snippet = None; if self.active.lines != state.original.lines { self.active.text_changed(); self.active.search_match = None; } self.active.lines.clone_from(&state.original.lines); self.active.targets.clone_from(&state.original.targets); self.active.primary = state.original.primary; } else { self.preview_completion( &state.original, &state.popup.items[state.popup.selected - 1], ); } self.completion = Some(state); } /// Retains the displayed candidate and records only its resolved native edits. fn finish_completion(&mut self) { let Some(state) = self.completion.take() else { return; }; if state.popup.selected == 0 || (state.original.lines == self.active.lines && self.snippet.is_none()) { return; } let item = state .popup .items .into_iter() .nth(state.popup.selected - 1) .expect("selected completion exists"); let caret = state.original.targets[state.original.primary].active; let origin = grapheme_offset(&state.original.lines, caret) as isize; let offsets = line_offsets(&state.original.lines); let mut batch = completion_edits( &state.original.lines, &offsets, state.original.primary, &item, ); let start = batch[item.primary_edit].start; batch.sort_by_key(|edit| (edit.start, edit.end)); let start = translate_edit_offset(start, false, batch.iter()); drop(batch); let offsets = line_offsets(&self.active.lines); let caret = cursor_offset(&self.active.lines, &offsets, self.cursor()) - start; if let (Some(snippet), Some(recipe)) = (&mut self.snippet, &self.active.pending_edit) { snippet.recipe_step = Some(recipe.steps.len()); } let edits = item .edits .into_iter() .enumerate() .map(|(index, edit)| RelativeCompletionEdit { start: grapheme_offset( &state.original.lines, Cursor { row: edit.start.row, column: edit.start.column, }, ) as isize - origin, end: grapheme_offset( &state.original.lines, Cursor { row: edit.end.row, column: edit.end.column, }, ) as isize - origin, text: edit.text, context: (index != item.primary_edit) .then(|| state.original.lines[edit.start.row..=edit.end.row].to_vec()), }) .collect(); self.push_insert_step(InsertStep::Completion { primary_edit: item.primary_edit, edits, caret, }); } fn select_snippet_stop(&mut self) { let Some(mut state) = self.snippet.take() else { return; }; let offsets = line_offsets(&self.active.lines); let stop = &state.tabstops[state.active]; let cursor = offset_cursor(&self.active.lines, &offsets, stop.ranges[0].start, true); self.active.targets[self.active.primary] = Target::caret(cursor); state.selected = true; state.selections.clear(); state .selections .extend(stop.ranges.iter().map(|range| Selection { start: offset_cursor(&self.active.lines, &offsets, range.start, true), end: offset_cursor(&self.active.lines, &offsets, range.end, true), })); self.sync_snippet_recipe(&state, false); self.reveal_caret(); if stop.index != 0 { self.snippet = Some(state); } } fn navigate_snippet(&mut self, next: bool) { let Some(state) = &mut self.snippet else { return; }; state.active = if next { (state.active + 1).min(state.tabstops.len() - 1) } else { state.active.saturating_sub(1) }; self.select_snippet_stop(); } fn sync_snippet_recipe(&mut self, state: &SnippetState, update_text: bool) { let Some(step) = state.recipe_step else { return; }; let offsets = line_offsets(&self.active.lines); let caret = cursor_offset(&self.active.lines, &offsets, self.cursor()) .saturating_sub(state.replacement.start); let text = update_text.then(|| byte_range_text(&self.active.lines, &offsets, &state.replacement)); if let Some(InsertStep::Completion { primary_edit, edits, caret: destination, }) = self .active .pending_edit .as_mut() .and_then(|recipe| recipe.steps.get_mut(step)) { *destination = caret; if let Some(text) = text { edits[*primary_edit].text = text; } } } // Only the primary argument and its declared mirrors participate in these edits. fn edit_snippet(&mut self, text: &str, deletion: Option) -> bool { let Some(mut state) = self.snippet.take() else { return false; }; let offsets = line_offsets(&self.active.lines); let stop = &state.tabstops[state.active]; let index = stop.index; let argument = stop.ranges[0].clone(); let caret = cursor_offset(&self.active.lines, &offsets, self.cursor()); if caret < argument.start || caret > argument.end { return false; } let mut value = byte_range_text(&self.active.lines, &offsets, &argument); let position = caret - argument.start; let (start, end) = if state.selected { (0, value.len()) } else if let Some(backwards) = deletion { if backwards { let start = value[..position] .grapheme_indices(true) .next_back() .map_or(position, |(byte, _)| byte); (start, position) } else { let end = value[position..] .graphemes(true) .next() .map_or(position, |grapheme| position + grapheme.len()); (position, end) } } else { (position, position) }; value.replace_range(start..end, text); let mut edits = Vec::with_capacity(stop.ranges.len()); let mut occurrence = 0; while occurrence < stop.ranges.len() { let range = &stop.ranges[occurrence]; let count = if range.is_empty() { stop.ranges[occurrence..] .iter() .take_while(|mirror| *mirror == range) .count() } else { 1 }; edits.push(Edit { target: (occurrence == 0).then_some(self.active.primary), start: range.start, end: range.end, text: if count == 1 { Cow::Borrowed(value.as_str()) } else { Cow::Owned(value.repeat(count)) }, caret: start + text.len(), round_up: true, }); occurrence += count; } edits.sort_by_key(|edit| (edit.start, edit.end)); // Retire overwritten nested occurrences, not independent mirrors outside the parent. state.tabstops.retain_mut(|other| { if other.index == index || other.index == 0 { return true; } let mut occurrence = 0; other.ranges.retain(|_| { let keep = !other.ancestors[occurrence].contains(&index); occurrence += 1; keep }); other .ancestors .retain(|ancestors| !ancestors.contains(&index)); !other.ranges.is_empty() }); state.active = state .tabstops .iter() .position(|stop| stop.index == index) .expect("edited stop is retained"); let (before, rest) = state.tabstops.split_at_mut(state.active); let (active, after) = rest.split_first_mut().expect("edited stop is retained"); for stop in before.iter_mut().chain(after) { for range in &mut stop.ranges { let descendant_at = |position| { active .ranges .iter() .zip(&active.ancestors) .any(|(edited, ancestors)| { edited.start == position && range.start <= edited.start && edited.end <= range.end && ancestors.contains(&stop.index) }) }; let descendant = active .ranges .iter() .zip(&active.ancestors) .any(|(edited, ancestors)| { range.start <= edited.start && edited.end <= range.end && ancestors.contains(&stop.index) }); let starts_after = !descendant && (Range::is_empty(range) || edits .iter() .any(|edit| edit.start == edit.end && edit.start == range.start)); let ends_after = Range::is_empty(range) && !descendant || !edits.iter().any(|edit| edit.start == range.end) || descendant_at(range.end); let start = translate_edit_offset(range.start, starts_after, edits.iter()); let end = translate_edit_offset(range.end, ends_after, edits.iter()); *range = start..end; } } let mut previous_empty = None; let mut coincident = 0; for range in &mut active.ranges { if Range::is_empty(range) && previous_empty == Some(range.start) { coincident += 1; } else { coincident = 0; } previous_empty = Range::is_empty(range).then_some(range.start); let start = translate_edit_offset(range.start, false, edits.iter()) + coincident * value.len(); *range = start..start + value.len(); } state.replacement = translate_edit_offset(state.replacement.start, false, edits.iter()) ..translate_edit_offset(state.replacement.end, true, edits.iter()); state.selected = false; self.apply_edits_from(edits, None, true); self.sync_snippet_recipe(&state, true); let offsets = line_offsets(&self.active.lines); // Grapheme merging with literal text can invalidate a stop: keep the text, end navigation. if state.tabstops.iter().any(|stop| { stop.ranges.windows(2).any(|pair| { pair[0].end > pair[1].start || pair[0].start == pair[1].start && (!pair[0].is_empty() || !pair[1].is_empty()) }) }) { return true; } if state .tabstops .iter() .flat_map(|stop| &stop.ranges) .any(|range| { !document_grapheme_boundary(&self.active.lines, &offsets, range.start) || !document_grapheme_boundary(&self.active.lines, &offsets, range.end) }) { return true; } state.selections.clear(); state .selections .extend( state.tabstops[state.active] .ranges .iter() .map(|range| Selection { start: offset_cursor(&self.active.lines, &offsets, range.start, true), end: offset_cursor(&self.active.lines, &offsets, range.end, true), }), ); self.snippet = Some(state); true } /// Borrowed popup state, visible only while its source remains current. pub fn hover(&self) -> Option<&Hover> { self.hover.as_ref().filter(|hover| { hover.buffer_id == self.active.id && hover.revision == self.text_revision() && hover.caret == self.cursor() && self.mode() == Mode::Normal && self.palette.is_none() && !self .file_picker .as_ref() .is_some_and(|picker| picker.focused) }) } pub fn show_hover(&mut self, text: String) -> Result<(), String> { validate_hover_text(&text)?; if self.mode() != Mode::Normal || self.palette.is_some() || self .file_picker .as_ref() .is_some_and(|picker| picker.focused) { return Err("Hover requires Normal editor focus".into()); } self.hover = if text.is_empty() { None } else { let text = match normalize_newlines(&text) { Cow::Borrowed(_) => text, Cow::Owned(normalized) => normalized, }; let mut line_starts = Vec::new(); let mut offset = 0; let mut max_column = 0; for line in text.split('\n') { line_starts.push(offset); offset += line.len() + 1; max_column = max_column.max(grapheme_count(line).saturating_sub(1)); } Some(Hover { text, line_starts, top: 0, left: 0, max_column, buffer_id: self.active.id, revision: self.text_revision(), caret: self.cursor(), }) }; Ok(()) } pub fn clear_hover(&mut self) { self.hover = None; } fn handle_hover(&mut self, input: &Input) -> bool { if self.hover().is_none() { self.clear_hover(); return false; } let hover = self.hover.as_mut().expect("current hover exists"); match input { Input::Up | Input::Ctrl('k') => hover.top = hover.top.saturating_sub(1), Input::Down | Input::Ctrl('j') => { hover.top = hover.top.saturating_add(1).min(hover.line_starts.len() - 1); } Input::Left | Input::Ctrl('h') => hover.left = hover.left.saturating_sub(1), Input::Right | Input::Ctrl('l') => { hover.left = hover.left.saturating_add(1).min(hover.max_column); } Input::Home => hover.left = 0, Input::End => hover.left = hover.max_column, Input::Escape => self.clear_hover(), _ => { self.clear_hover(); return false; } } true } 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()) } /// Whether the built-in `g` navigation prefix is awaiting its second key. pub fn g_pending(&self) -> bool { self.g_pending } pub fn line_operator_pending(&self) -> Option { self.line_pending } fn clear_leader(&mut self) { self.g_pending = false; self.line_pending = None; self.leader_suffix.clear(); self.leader_display.clear(); } fn cancel_leader(&mut self) { if self.leader_pending().is_some() || self.g_pending || self.line_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 } /// Whole-row shape belongs to the active buffer, not the Visual binding context. pub fn visual_linewise(&self) -> bool { self.active.mode == Mode::Visual && self.active.linewise } /// Inclusive selected rows, including an empty final row with an empty text range. pub fn linewise_rows(&self) -> impl Iterator> + '_ { self.active .targets .iter() .filter(|_| self.visual_linewise()) .map(|target| { target.anchor.row.min(target.active.row)..=target.anchor.row.max(target.active.row) }) } /// 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(); let native = match action { Some(Action::FollowLink) if self.active.mode == Mode::Normal => Some("gf"), Some(Action::DeleteLine) if self.active.mode == Mode::Normal => Some("dd"), Some(Action::ChangeLine) if self.active.mode == Mode::Normal => Some("cc"), Some(Action::DocumentStart) if matches!(self.active.mode, Mode::Normal | Mode::Visual) => { Some("gt") } Some(Action::DocumentEnd) if matches!(self.active.mode, Mode::Normal | Mode::Visual) => { Some("gb") } _ => None, }; if let Some(native) = native { let prefix = Input::Char(native.as_bytes()[0] as char); if self.leader != prefix && !self .bindings .get(&self.active.mode) .is_some_and(|keys| keys.contains_key(&prefix)) { bindings.push(native.into()); } } 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 }; let source = self.jump_location(); match self.edit_buffer(path, false) { Ok(()) => { self.record_jump(source); 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, }; if self.completion.is_some() { let request = matches!( self.bindings .get(&self.active.mode) .and_then(|keys| keys.get(&input)), Some(Binding::Action(Action::LspCompletion)) ); if request || input == Input::Ctrl('j') { self.cycle_completion(true); return None; } match input { Input::Ctrl('k') => { self.cycle_completion(false); return None; } _ => self.finish_completion(), } } if std::mem::take(&mut self.g_pending) { if matches!(self.active.mode, Mode::Normal | Mode::Visual) && self.startup_diagnostic.is_none() && self.hover().is_none() && self.palette.is_none() && !self .file_picker .as_ref() .is_some_and(|picker| picker.focused) { let action = match input { Input::Char('f') if self.active.mode == Mode::Normal => { Some(Action::FollowLink) } Input::Char('t') => Some(Action::DocumentStart), Input::Char('b') => Some(Action::DocumentEnd), _ => None, }; if let Some(action) = action { return self.act(action); } } self.count = None; if input == Input::Escape { return None; } } if let Some(diagnostic) = &mut self.startup_diagnostic { match input { Input::Enter | Input::Escape => self.startup_diagnostic = None, Input::Up | Input::Ctrl('k') => diagnostic.top = diagnostic.top.saturating_sub(1), Input::Down | Input::Ctrl('j') => { diagnostic.top = diagnostic .top .saturating_add(1) .min(diagnostic.lines.len() - 1) } Input::Left | Input::Ctrl('h') => { diagnostic.left = diagnostic.left.saturating_sub(1) } Input::Right | Input::Ctrl('l') => { diagnostic.left = diagnostic.left.saturating_add(1).min(diagnostic.max_column) } Input::Home => { diagnostic.top = 0; diagnostic.left = 0; } _ => {} } return None; } if self.handle_hover(&input) { return None; } 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); } if let Some(operator) = self.line_pending.take() { if self.active.mode == Mode::Normal && input == Input::Char(operator) { return self.act(if operator == 'd' { Action::DeleteLine } else { Action::ChangeLine }); } self.count = None; if input == Input::Escape { return None; } } // 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; } if matches!(self.active.mode, Mode::Normal | Mode::Visual) && input == Input::Char('g') { self.count = None; self.g_pending = true; return None; } if self.active.mode == Mode::Normal && let Input::Char(operator @ ('c' | 'd')) = input { self.line_pending = Some(operator); 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 { self.finish_completion(); if !matches!( action, Action::SnippetNext | Action::SnippetPrevious | Action::Newline | Action::Backspace | Action::Delete ) { self.snippet = None; } if !matches!(action, Action::JumpBack | Action::JumpForward) { self.clear_hover(); } let count = self.count.take().unwrap_or(1); self.clear_leader(); match action { Action::SnippetNext => { if self.snippet.is_some() { self.navigate_snippet(true); } else if self.active.mode == Mode::Insert { self.type_native("\t"); } } Action::SnippetPrevious => self.navigate_snippet(false), 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::FollowLink => { if let Err(error) = self.follow_link() { self.set_message(error); } } Action::JumpBack => self.traverse_jumps(false, count), Action::JumpForward => self.traverse_jumps(true, count), Action::DeleteLine | Action::ChangeLine => { self.edit_lines(matches!(action, Action::ChangeLine), count); } Action::LspDefinition => return self.execute("lsp.definition"), Action::LspHover => return self.execute("lsp.hover"), Action::LspDiagnostics => return self.execute("lsp.diagnostics"), Action::LspCompletion => { if self.active.mode == Mode::Insert { return Some(Invocation { plugin: "core".into(), command: "lsp.completion".into(), args: String::new(), }); } } 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::DocumentStart | Action::DocumentEnd | 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 => { if self.active.mode == Mode::Insert && self.edit_snippet("", Some(false)) { return None; } 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 | Action::SelectLinewise => { let linewise = matches!(action, Action::SelectLinewise); if self.active.mode == Mode::Visual && self.active.linewise == linewise { self.clear_selection(); } else { if self.active.mode != Mode::Visual { for target in &mut self.active.targets { target.anchor = target.active; } self.active.visual_motions = Some(Vec::new()); } else { self.active.visual_motions = None; } self.active.linewise = linewise; self.active.mode = Mode::Visual; 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 { if self.edit_snippet("", Some(true)) { return None; } 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.reveal_caret(); let source = self.jump_location(); 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(); self.record_jump(source); } fn jump_location(&self) -> JumpLocation { JumpLocation { buffer_id: self.active.id, cursor: self.cursor(), } } fn record_jump(&mut self, source: JumpLocation) { let destination = self.jump_location(); if source == destination { return; } if self.jumps.is_empty() { self.jumps.push_back(source); } else { self.jumps.truncate(self.jump_index + 1); self.jumps[self.jump_index] = source; } self.jumps.push_back(destination); if self.jumps.len() > 100 { self.jumps.pop_front(); } self.jump_index = self.jumps.len() - 1; } fn traverse_jumps(&mut self, forward: bool, count: usize) { self.reveal_caret(); let departure = self.jump_location(); let mut destination = departure; let mut index = self.jump_index; let mut remaining = count; let mut destination_index = None; // The timeline bounds this scan, even for a saturated count. while remaining > 0 { let next = if forward { index.checked_add(1).filter(|next| *next < self.jumps.len()) } else { index.checked_sub(1) }; let Some(next) = next else { break }; index = next; let mut location = self.jumps[index]; let Some(buffer) = self.buffer(location.buffer_id) else { continue; }; location.cursor.row = location.cursor.row.min(buffer.lines.len() - 1); location.cursor.column = location .cursor .column .min(grapheme_count(&buffer.lines[location.cursor.row]).saturating_sub(1)); if location == destination { continue; } destination = location; destination_index = Some(index); remaining -= 1; } let Some(index) = destination_index else { self.set_message(if forward { "No newer jump location" } else { "No older jump location" }); return; }; self.jumps[self.jump_index] = departure; self.switch_buffer(destination.buffer_id); self.place_navigation(TextPosition { row: destination.cursor.row, column: destination.cursor.column, }); self.jump_index = index; } fn park_source(&mut self) { self.finish_completion(); self.snippet = None; self.clear_hover(); 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.reveal_caret(); 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.add_buffer(buffer); Ok(()) } fn add_buffer(&mut self, buffer: Buffer) { self.next_buffer_id += 1; self.activate(buffer); } 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 { let source = self.jump_location(); 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, } if matches!(name, "edit" | "buffer" | "bnext" | "bprev") { self.record_jump(source); } true } fn any_dirty(&self) -> bool { self.active.dirty() || self.parked.values().any(Buffer::dirty) } pub fn execute(&mut self, command: &str) -> Option { self.finish_completion(); self.snippet = None; self.clear_hover(); 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; } // Link preflight must precede Ex cleanup: failure retains carets and text. if name == "follow_link" { if !args.is_empty() { self.set_message("The follow_link command takes no arguments"); } else if let Err(error) = self.follow_link() { self.set_message(error); } 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 { "lsp.definition" | "lsp.hover" | "lsp.diagnostics" if !args.is_empty() => { self.set_message(format!("The {name} command takes no arguments")); } "lsp.definition" | "lsp.hover" | "lsp.diagnostics" => { return Some(Invocation { plugin: "core".into(), command: name.into(), args: String::new(), }); } "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(); } self.finish_completion(); 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.clear_hover(); 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); if self.active.path.as_ref() != Some(&path) { self.active.semantic_tokens = None; } 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 { if self.visual_linewise() { let first = target.anchor.row.min(target.active.row); let last = target.anchor.row.max(target.active.row); return Selection { start: Cursor { row: first, column: 0, }, end: if last + 1 < self.active.lines.len() { Cursor { row: last + 1, column: 0, } } else { Cursor { row: last, column: grapheme_count(&self.active.lines[last]), } }, }; } 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) { if self.active.mode != Mode::Visual { self.active.linewise = false; } 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; } if self.visual_linewise() { let mut ordered: Vec<_> = self .active .targets .iter() .copied() .enumerate() .map(|(index, target)| (target, index == self.active.primary)) .collect(); ordered.sort_by_key(|(target, _)| { ( target.anchor.row.min(target.active.row), target.anchor.row.max(target.active.row), ) }); let mut merged: Vec<(Target, bool)> = Vec::with_capacity(ordered.len()); for (target, primary) in ordered { if let Some((previous, was_primary)) = merged.last_mut() && target.anchor.row.min(target.active.row) <= previous.anchor.row.max(previous.active.row) { let first = previous .anchor .row .min(previous.active.row) .min(target.anchor.row.min(target.active.row)); let last = previous .anchor .row .max(previous.active.row) .max(target.anchor.row.max(target.active.row)); let representative = if primary { target } else { *previous }; *previous = representative; if representative.active >= representative.anchor { previous.anchor.row = first; previous.active.row = last; } else { previous.anchor.row = last; previous.active.row = first; } previous.anchor.column = previous .anchor .column .min(self.max_column(previous.anchor.row)); previous.active.column = previous .active .column .min(self.max_column(previous.active.row)); *was_primary |= primary; } else { merged.push((target, primary)); } } if merged.len() != self.active.targets.len() { self.active.visual_motions = None; } 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)); return; } 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 rows: Vec<_> = if self.visual_linewise() { self.linewise_rows().collect() } else { self.selections() .into_iter() .map(|range| { let last = if range.end.column == 0 && range.end.row > range.start.row { range.end.row - 1 } else { range.end.row }; range.start.row..=last }) .collect() }; let primary_row = self.cursor().row; let mut carets = Vec::new(); let mut primary = 0; for (index, rows) in rows.into_iter().enumerate() { for row in rows { 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) { // Occurrences are literal text ranges, so rebuilding them is characterwise. if self.visual_linewise() { let ranges = self.selections(); let targets = self .active .targets .iter() .zip(ranges) .map(|(target, range)| self.range_target(range, target.active >= target.anchor)) .collect(); self.active.targets = targets; self.active.linewise = false; self.active.visual_motions = None; } 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 line_selections(&mut self, count: usize) -> Vec { self.active.mode = Mode::Visual; self.active.linewise = true; self.active.visual_motions = None; let last = self.active.lines.len() - 1; for target in &mut self.active.targets { target.anchor = Cursor { row: target.active.row, column: 0, }; target.active = Cursor { row: target .anchor .row .saturating_add(count.saturating_sub(1)) .min(last), column: 0, }; target.preferred_columns = None; } self.normalize_targets(); self.selections() } fn edit_lines(&mut self, change: bool, count: usize) { let targets = self.active.targets.clone(); let primary = self.active.primary; let ranges = self.line_selections(count); let fragments = self.selection_fragments(&ranges); if let Err(error) = self.store_clipboard( fragments, self.clipboard_mode == ClipboardMode::System, true, ) { self.active.targets = targets; self.active.primary = primary; self.active.mode = Mode::Normal; self.active.linewise = false; self.set_message(error); return; } let before = Snapshot { lines: self.active.lines.clone(), targets, primary, }; let start = EditStart::Lines { change, count }; if change { self.start_insert(before, start); self.edit_ranges(Action::Change, &ranges); } else { self.edit_ranges(Action::Cut, &ranges); self.active.mode = Mode::Normal; self.normalize_targets(); self.record_native( before, EditRecipe { start, steps: Vec::new(), }, ); } self.message.clear(); self.reveal_caret(); } 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.visual_linewise(), ) { 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 { if self.active.linewise { return self .active .targets .iter() .map(|target| { let first = target.anchor.row.min(target.active.row); let last = target.anchor.row.max(target.active.row); let mut fragment = self.active.lines[first..=last].join("\n"); fragment.push('\n'); fragment }) .collect(); } 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, linewise: bool, ) -> Result<(), String> { if fragments.iter().all(|fragment| fragment.is_empty()) { return Ok(()); } if system { let exported = fragments.join(if linewise { "" } else { "\n" }); self.system_clipboard.write(&exported)?; self.owned_system_clipboard = Some((exported, fragments, linewise)); } else { self.clipboard = fragments; self.clipboard_linewise = linewise; } Ok(()) } // Range mutation has no register/history side effects. 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); if self.active.linewise { let last_row = self.active.lines.len() - 1; let eof = offsets[last_row] + self.active.lines[last_row].len(); let changing = matches!(action, Action::Change); self.active.mode = Mode::Insert; self.active.linewise = false; if cut { let tail_start = if !changing && self.active.targets.last().is_some_and(|target| { target.anchor.row.max(target.active.row) == last_row }) { let mut index = self.active.targets.len() - 1; while index > 0 { let current = self.active.targets[index]; let previous = self.active.targets[index - 1]; if previous.anchor.row.max(previous.active.row) + 1 != current.anchor.row.min(current.active.row) { break; } index -= 1; } Some(index) } else { None }; let edits = self .active .targets .iter() .enumerate() .map(|(target, item)| { let first = item.anchor.row.min(item.active.row); let last = item.anchor.row.max(item.active.row); let mut start = offsets[first]; let end = if last < last_row { offsets[last + 1] } else { eof }; // Remove the tail's preceding separator once, at its first edit. if tail_start == Some(target) && first > 0 { start -= 1; } Edit { target: Some(target), start, end, text: Cow::Borrowed(if changing && last < last_row { "\n" } else { "" }), caret: 0, round_up: true, } }) .collect(); self.apply_edits(edits); if !changing { for target in &mut self.active.targets { target.active.column = first_nonblank(&self.active.lines[target.active.row]); } } } else { for target in &mut self.active.targets { target.active = Cursor { row: target.anchor.row.min(target.active.row), column: 0, }; } } return; } self.active.mode = Mode::Insert; if cut { let edits = ranges .iter() .enumerate() .map(|(target, range)| Edit { target: Some(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<(Vec, bool), String> { let text = self.system_clipboard.read()?; validate_paste_text(&text)?; if let Some((exported, fragments, linewise)) = &self.owned_system_clipboard && *exported == text { return Ok((fragments.clone(), *linewise)); } self.owned_system_clipboard = None; Ok(( if text.is_empty() { Vec::new() } else { vec![normalize_newlines(&text).into_owned()] }, false, )) } fn paste_clipboard(&mut self, after: bool, system: bool) { let (mut fragments, linewise) = if system { match self.read_system_clipboard() { Ok(payload) => payload, Err(error) => { self.set_message(error); return; } } } else { (std::mem::take(&mut self.clipboard), self.clipboard_linewise) }; 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(); if let Err(error) = self.paste_fragments(after, &fragments, linewise) { self.set_message(error); if !system { self.clipboard = fragments; } return; } if before.lines != self.active.lines { self.last_edit = Some(EditRecipe { start: EditStart::Paste { after, linewise, 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], linewise: bool, ) -> Result<(), &'static str> { let offsets = line_offsets(&self.active.lines); if linewise { if fragments.len() != 1 && self .active .targets .windows(2) .enumerate() .any(|(index, targets)| { targets[0].active.row == targets[1].active.row && fragments[index] != fragments[index + 1] }) { return Err("Conflicting linewise clipboard fragments target the same row"); } let last = self.active.lines.len() - 1; let edits = self .active .targets .iter() .enumerate() .map(|(target, item)| { let fragment = &fragments[if fragments.len() == 1 { 0 } else { target }]; let row = item.active.row; let (start, text, prefix) = if after && row == last { let mut text = String::with_capacity(fragment.len()); text.push('\n'); text.push_str(fragment.strip_suffix('\n').unwrap_or(fragment)); ( offsets[row] + self.active.lines[row].len(), Cow::Owned(text), 1, ) } else { ( offsets[row + usize::from(after)], Cow::Borrowed(fragment.as_str()), 0, ) }; let first = fragment.split('\n').next().unwrap(); Edit { target: Some(target), start, end: start, caret: prefix + grapheme_byte(first, first_nonblank(first)), text, round_up: true, } }) .collect(); self.active.mode = Mode::Insert; self.active.linewise = false; self.apply_edits(edits); self.active.mode = Mode::Normal; self.normalize_targets(); return Ok(()); } 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: Some(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(); Ok(()) } 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) { self.finish_completion(); self.snippet = None; 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) { if self.edit_snippet(text, None) { return; } 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 self.visual_linewise() { self.active.visual_motions = None; let mut rows: Vec<_> = self .active .targets .iter() .map(|target| { let count = target.anchor.row.abs_diff(target.active.row) as isize + 1; if target.active >= target.anchor { count } else { -count } }) .collect(); if rows.iter().all(|row| *row == rows[0]) { rows.truncate(1); } return SelectionIntent::Rows(rows); } 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()), EditStart::Selection { selector: SelectionIntent::Rows(rows), .. } if self.active.mode != Mode::Visual => Some(rows.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 mut before = self.snapshot(); let mode = self.active.mode; let selector = self.active.visual_motions.take(); let linewise = self.active.linewise; let search_match = self.active.search_match; let revision = self.active.text_revision; let diagnostics = std::mem::take(&mut self.active.diagnostics); let semantic_tokens = self.active.semantic_tokens.take(); let mut restored = false; let result = self .replay_edit(&recipe) .map_err(str::to_owned) .and_then(|fragments| { if let Some((fragments, shape)) = fragments { // Validate the entire replay first, then publish while the original // document is restored, and only then commit the computed deletion. std::mem::swap(&mut self.active.lines, &mut before.lines); std::mem::swap(&mut self.active.targets, &mut before.targets); std::mem::swap(&mut self.active.primary, &mut before.primary); restored = true; self.store_clipboard( fragments, self.clipboard_mode == ClipboardMode::System, shape, )?; std::mem::swap(&mut self.active.lines, &mut before.lines); std::mem::swap(&mut self.active.targets, &mut before.targets); std::mem::swap(&mut self.active.primary, &mut before.primary); restored = false; } Ok(()) }); match result { Ok(()) => { if self.active.text_revision == revision { self.active.diagnostics = diagnostics; self.active.semantic_tokens = semantic_tokens; } self.active.visual_motions = None; self.record(before); self.message.clear(); } Err(error) => { if !restored { self.active.lines = before.lines; self.active.targets = before.targets; self.active.primary = before.primary; } self.active.text_revision = revision; self.active.diagnostics = diagnostics; self.active.semantic_tokens = semantic_tokens; self.active.mode = mode; self.active.linewise = linewise; 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, bool)>, &'static str> { let mut clipboard = None; match &recipe.start { EditStart::Insert(action) => self.place_insert(*action), EditStart::Lines { change, count } => { let ranges = self.line_selections(*count); clipboard = Some((self.selection_fragments(&ranges), true)); self.edit_ranges(if *change { Action::Change } else { Action::Cut }, &ranges); if !*change { self.active.mode = Mode::Normal; self.normalize_targets(); } } EditStart::Selection { change, selector } => { let ranges = self.replay_ranges(selector); clipboard = Some((self.selection_fragments(&ranges), self.visual_linewise())); 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, linewise, } => self.paste_fragments(*after, fragments, *linewise)?, } 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); } } InsertStep::Completion { primary_edit, edits, caret, } => { let origin = grapheme_offset(&self.active.lines, self.cursor()); let resolve = |distance: isize| { origin .checked_add_signed(distance) .and_then(|index| grapheme_cursor(&self.active.lines, index)) .ok_or("Repeat completion range is outside the document") }; let offsets = line_offsets(&self.active.lines); let mut batch = Vec::with_capacity(edits.len()); for (index, edit) in edits.iter().enumerate() { let start = resolve(edit.start)?; let end = resolve(edit.end)?; if edit.context.as_ref().is_some_and(|context| { *context != self.active.lines[start.row..=end.row] }) { return Err("Repeat completion additional-edit context changed"); } // Translating every boundary by the same origin preserves // the validated ordering and nonoverlap of the original batch. batch.push(Edit { target: (index == *primary_edit).then_some(self.active.primary), start: cursor_offset(&self.active.lines, &offsets, start), end: cursor_offset(&self.active.lines, &offsets, end), text: Cow::Borrowed(&edit.text), caret: if index == *primary_edit { *caret } else { edit.text.len() }, round_up: true, }); } self.apply_edits_from(batch, None, true); } } } 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::Rows(rows) => { self.active.linewise = true; let last = self.active.lines.len() - 1; for (index, target) in self.active.targets.iter_mut().enumerate() { target.anchor = target.active; let count = rows[if rows.len() == 1 { 0 } else { index }]; let distance = count.unsigned_abs().saturating_sub(1); target.active.row = if count >= 0 { target.active.row.saturating_add(distance).min(last) } else { target.active.row.saturating_sub(distance) }; } self.normalize_targets(); self.selections() } 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) { self.snippet = None; 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) { self.reveal_caret(); 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), Action::DocumentStart | Action::DocumentEnd => { cursor.row = if action == Action::DocumentStart { 0 } else { self.active.lines.len() - 1 }; cursor.column = first_nonblank(&self.active.lines[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, edits: Vec>) { self.apply_edits_from(edits, None, false); } fn apply_edits_from( &mut self, mut edits: Vec>, original: Option<&Snapshot>, preserve_targets: bool, ) { 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 lines = original.map_or(&self.active.lines, |before| &before.lines); let targets = original.map_or(&self.active.targets, |before| &before.targets); let old = lines.join("\n"); let offsets = line_offsets(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| { translate_edit_offset(position, after, unique.iter().map(|index| &edits[*index])) }; let mut destinations: Vec<_> = targets .iter() .map(|target| { ( remap(cursor_offset(lines, &offsets, target.active), true), true, ) }) .collect(); for edit in &edits { if let Some(target) = edit.target { destinations[target] = (remap(edit.start, false) + edit.caret, edit.round_up); } } if !text .split('\n') .eq(self.active.lines.iter().map(String::as_str)) { self.active.text_changed(); self.active.search_match = None; } if let Some(original) = original { self.active.targets.clone_from(&original.targets); self.active.primary = original.primary; } 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; } if !preserve_targets { 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: Some(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: Some(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: Some(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: Some(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: Some(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); command.split('.').all(|segment| { let mut chars = segment.chars(); chars.next().is_some_and(|ch| ch.is_ascii_alphabetic()) && chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-') }) } fn grapheme_offset(lines: &[String], cursor: Cursor) -> usize { lines[..cursor.row] .iter() .map(|line| grapheme_count(line) + 1) .sum::() + cursor.column } fn grapheme_cursor(lines: &[String], mut index: usize) -> Option { for (row, line) in lines.iter().enumerate() { let columns = grapheme_count(line); if index <= columns { return Some(Cursor { row, column: index }); } index -= columns + 1; } None } fn translate_edit_offset<'a, 'b>( position: usize, after: bool, edits: impl Iterator>, ) -> usize where 'b: 'a, { let mut removed = 0; let mut added = 0; for edit in edits { 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 } fn byte_range_text(lines: &[String], offsets: &[usize], range: &Range) -> String { let first = offsets.partition_point(|offset| *offset <= range.start) - 1; let last = offsets.partition_point(|offset| *offset <= range.end) - 1; let mut text = String::with_capacity(range.end - range.start); for row in first..=last { if row != first { text.push('\n'); } let start = if row == first { range.start - offsets[row] } else { 0 }; let end = if row == last { range.end - offsets[row] } else { lines[row].len() }; text.push_str(&lines[row][start..end]); } text } fn document_grapheme_boundary(lines: &[String], offsets: &[usize], byte: usize) -> bool { let Some(row) = offsets .partition_point(|offset| *offset <= byte) .checked_sub(1) else { return false; }; let line = &lines[row]; let column = byte - offsets[row]; column <= line.len() && line.is_char_boundary(column) && GraphemeCursor::new(column, line.len(), true) .is_boundary(line, 0) .expect("complete line supplies grapheme context") } fn validate_snippet(text: &str, stops: &[SnippetTabstop]) -> Result { if stops.is_empty() || stops.last().is_none_or(|stop| stop.index != 0) { return Err("Snippet requires a final caret stop".into()); } if stops.len() > MAX_EDIT_BYTES / std::mem::size_of::() { return Err("Snippet metadata exceeds 4 MiB".into()); } let mut metadata_bytes = std::mem::size_of_val(stops); let mut ranges = Vec::new(); let mut previous = 0; for (group, stop) in stops.iter().enumerate() { if stop.ranges.is_empty() || stop.ancestors.len() != stop.ranges.len() || stop.index == 0 && group + 1 != stops.len() || stop.index != 0 && stop.index <= previous || stop.index == 0 && (stop.ranges.len() != 1 || !stop.ranges[0].is_empty()) { return Err("Snippet tabstops are unordered or empty".into()); } previous = stop.index; let mut end = None; let mut mirror = None; for (occurrence, range) in stop.ranges.iter().enumerate() { if range.start > range.end || range.end > text.len() { return Err("Snippet range is outside its replacement".into()); } for byte in [range.start, range.end] { if !text.is_char_boundary(byte) || !GraphemeCursor::new(byte, text.len(), true) .is_boundary(text, 0) .expect("complete replacement supplies grapheme context") { return Err("Snippet range splits a grapheme".into()); } } if end.is_some_and(|(start, end)| { range.start < end || range.start == start && !(range.is_empty() && start == end) }) { return Err("Snippet mirror ranges overlap or are unordered".into()); } end = Some((range.start, range.end)); let value = &text[range.clone()]; if mirror.is_some_and(|mirror| mirror != value) { return Err("Snippet mirrors have different defaults".into()); } mirror = Some(value); if stop.ancestors[occurrence].len() > 128 { return Err("Snippet nesting exceeds 128 levels".into()); } ranges.push((range.start, range.end, group, occurrence)); metadata_bytes += std::mem::size_of::>() + std::mem::size_of::>() + stop.ancestors[occurrence].len() * std::mem::size_of::(); if metadata_bytes > MAX_EDIT_BYTES { return Err("Snippet metadata exceeds 4 MiB".into()); } } } let numbered = &stops[..stops.len() - 1]; for stop in stops { for (range, ancestors) in stop.ranges.iter().zip(&stop.ancestors) { if stop.index == 0 && !ancestors.is_empty() { return Err("Snippet final caret must be outside nested defaults".into()); } for (depth, ancestor) in ancestors.iter().enumerate() { if *ancestor == stop.index || ancestors[..depth].contains(ancestor) { return Err("Snippet ancestry contains a cycle".into()); } let parent = numbered .binary_search_by_key(ancestor, |stop| stop.index) .map(|index| &numbered[index]) .map_err(|_| "Snippet ancestry references an unknown tabstop")?; if !parent .ranges .iter() .zip(&parent.ancestors) .any(|(enclosing, ancestry)| { enclosing.start <= range.start && range.end <= enclosing.end && ancestry.as_slice() == &ancestors[..depth] }) { return Err("Snippet ancestor does not contain its child".into()); } } } } // Nested and coincident defaults require declared ancestry; crossing ranges are invalid. ranges.sort_unstable_by_key(|(start, end, group, occurrence)| { ( *start, std::cmp::Reverse(*end), stops[*group].ancestors[*occurrence].len(), ) }); let mut enclosing: Vec<(usize, usize, usize)> = Vec::new(); for (start, end, group, occurrence) in ranges { while enclosing .last() .is_some_and(|(_, parent_end, _)| *parent_end <= start) { enclosing.pop(); } if let Some((parent_start, parent_end, parent)) = enclosing.last() { if end > *parent_end { return Err("Snippet placeholder ranges cross".into()); } if stops[group].index != 0 && (start != end || start > *parent_start) && !stops[group].ancestors[occurrence].contains(&stops[*parent].index) { return Err("Snippet overlapping placeholders require ancestry".into()); } } if start != end { enclosing.push((start, end, group)); } } Ok(metadata_bytes) } fn validate_completion_items(lines: &[String], items: &[CompletionItem]) -> Result<(), String> { let mut total = 0usize; let offsets = line_offsets(lines); let document = items .iter() .any(|item| item.snippet.is_some()) .then(|| lines.join("\n")); for item in items { if item.primary_edit >= item.edits.len() { return Err("Completion has no primary edit".into()); } for text in std::iter::once(item.label.as_str()) .chain(item.detail.as_deref()) .chain(item.edits.iter().map(|edit| edit.text.as_str())) { total = total .checked_add(text.len()) .ok_or("Completions exceed 4 MiB")?; if total > MAX_EDIT_BYTES { return Err("Completions exceed 4 MiB".into()); } validate_text(text)?; } let mut ranges = Vec::with_capacity(item.edits.len()); for edit in &item.edits { for position in [edit.start, edit.end] { if lines .get(position.row) .is_none_or(|line| position.column > grapheme_count(line)) { return Err("Completion range is outside the document".into()); } } let start = cursor_offset( lines, &offsets, Cursor { row: edit.start.row, column: edit.start.column, }, ); let end = cursor_offset( lines, &offsets, Cursor { row: edit.end.row, column: edit.end.column, }, ); if start > end { return Err("Completion range is reversed".into()); } ranges.push((start, end)); } ranges.sort_unstable(); if ranges .windows(2) .any(|pair| pair[0].1 > pair[1].0 || pair[0].0 == pair[1].0) { return Err("Completion edits overlap".into()); } if let Some(snippet) = &item.snippet { let text = &item.edits[item.primary_edit].text; if text.contains('\r') { return Err( "Snippet replacement must normalize newlines before assigning ranges".into(), ); } total = total .checked_add(validate_snippet(text, &snippet.tabstops)?) .filter(|bytes| *bytes <= MAX_EDIT_BYTES) .ok_or("Completions exceed 4 MiB")?; let mut edits = completion_edits(lines, &offsets, 0, item); let primary_start = edits[item.primary_edit].start; for (edit, source) in edits.iter_mut().zip(&item.edits) { edit.text = normalize_newlines(&source.text); } edits.sort_by_key(|edit| (edit.start, edit.end)); let base = translate_edit_offset(primary_start, false, edits.iter()); let original = document .as_deref() .expect("snippet validation has document text"); let removed: usize = edits.iter().map(|edit| edit.end - edit.start).sum(); let added: usize = edits.iter().map(|edit| edit.text.len()).sum(); let mut expanded = String::with_capacity(original.len() - removed + added); let mut consumed = 0; for edit in &edits { expanded.push_str(&original[consumed..edit.start]); expanded.push_str(&edit.text); consumed = edit.end; } expanded.push_str(&original[consumed..]); for range in snippet.tabstops.iter().flat_map(|stop| &stop.ranges) { for byte in [base + range.start, base + range.end] { if !GraphemeCursor::new(byte, expanded.len(), true) .is_boundary(&expanded, 0) .expect("complete document supplies grapheme context") { return Err("Snippet range merges with surrounding graphemes".into()); } } } } } Ok(()) } fn completion_edits<'a>( lines: &[String], offsets: &[usize], primary: usize, item: &'a CompletionItem, ) -> Vec> { item.edits .iter() .enumerate() .map(|(index, edit)| Edit { target: (index == item.primary_edit).then_some(primary), start: cursor_offset( lines, offsets, Cursor { row: edit.start.row, column: edit.start.column, }, ), end: cursor_offset( lines, offsets, Cursor { row: edit.end.row, column: edit.end.column, }, ), text: Cow::Borrowed(&edit.text), caret: edit.text.len(), round_up: true, }) .collect() } pub(crate) fn validate_hover_text(text: &str) -> Result<(), String> { if text.len() > MAX_EDIT_BYTES { Err("Hover exceeds 4 MiB".into()) } else { validate_text(text) } } 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 { if line.is_ascii() { // CRLF is the only multi-byte ASCII grapheme. line.len() - line.matches("\r\n").count() } else { 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)] // These fixtures intentionally contain ranges, not lists of indices. #[allow(clippy::single_range_in_vec_init)] mod tests { use super::*; use crate::plugin::CompletionEdit; 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) }); } } #[test] fn line_actions_counted_delete_restores_caret_and_pastes_whole_rows() { let mut editor = editor(); let source = "zero\n Ξ±one\n\tlast\nend"; editor.apply(vec![Effect::Replace(source.into())]).unwrap(); type_text(&mut editor, "jll2dd"); assert_eq!(editor.context().text, "zero\nend"); assert_eq!(editor.mode(), Mode::Normal); assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 }); type_text(&mut editor, "u"); assert_eq!(editor.context().text, source); assert_eq!(editor.cursor(), Cursor { row: 1, column: 2 }); type_text(&mut editor, "UP"); assert_eq!(editor.context().text, source); } #[test] fn line_actions_counted_change_groups_typing_and_repeats_resolved_rows() { let mut editor = editor(); let source = "keep\n one\ntwo\nend"; editor.apply(vec![Effect::Replace(source.into())]).unwrap(); type_text(&mut editor, "j2cc"); assert_eq!(editor.context().text, "keep\n\nend"); assert_eq!(editor.mode(), Mode::Insert); assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 }); type_text(&mut editor, "new"); editor.handle(Input::Escape); type_text(&mut editor, "u"); assert_eq!(editor.context().text, source); type_text(&mut editor, "Uj."); assert_eq!(editor.context().text, "keep\nnew\nnew"); assert_eq!(editor.mode(), Mode::Normal); type_text(&mut editor, "u"); assert_eq!(editor.context().text, "keep\nnew\nend"); } #[test] fn line_actions_eof_empty_rows_and_repeat_keep_the_requested_count() { for (source, keys, expected) in [ ("a\nb", "jdd", "a"), ("a\nb\n", "jdd", "a\n"), ("a\nb\n", "gbdd", "a\nb"), ("a", "dd", ""), ("", "dd", ""), ("a\nb\nc", "2dd", "c"), ("a\nb", "jcc", "a\n"), ] { let mut editor = editor(); editor.apply(vec![Effect::Replace(source.into())]).unwrap(); type_text(&mut editor, keys); assert_eq!(editor.context().text, expected, "{keys} on {source:?}"); if editor.mode() == Mode::Insert { editor.handle(Input::Escape); } if source != expected { type_text(&mut editor, "u"); assert_eq!(editor.context().text, source); } } let directory = Directory::new(); let target = directory.path("repeat"); fs::write(&target, "1\n2\n3\n4\n5").unwrap(); let mut editor = editor(); editor .apply(vec![Effect::Replace("a\nb\nc".into())]) .unwrap(); type_text(&mut editor, "gb3dd"); assert_eq!(editor.context().text, "a\nb"); editor.execute(&format!("edit {}", target.display())); type_text(&mut editor, "."); assert_eq!(editor.context().text, "4\n5"); type_text(&mut editor, "u"); assert_eq!(editor.context().text, "1\n2\n3\n4\n5"); type_text(&mut editor, &format!("{}dd", usize::MAX)); assert_eq!(editor.context().text, ""); } #[test] fn line_actions_merge_overlaps_but_keep_adjacent_change_carets() { let mut editor = editor(); let source = "aa\nbb\ncc\ndd\nee"; editor.apply(vec![Effect::Replace(source.into())]).unwrap(); editor.handle(Input::Ctrl('e')); let carets = editor.carets(); type_text(&mut editor, "2dd"); assert_eq!(editor.context().text, "dd\nee"); type_text(&mut editor, "u"); assert_eq!(editor.context().text, source); assert_eq!(editor.carets(), carets); type_text(&mut editor, "UP"); assert_eq!(editor.context().text, source); editor.handle(Input::Escape); editor.handle(Input::Ctrl('e')); type_text(&mut editor, "ccX"); editor.handle(Input::Escape); assert_eq!(editor.context().text, "X\nX\ncc\ndd\nee"); type_text(&mut editor, "u"); assert_eq!(editor.context().text, source); } #[test] fn line_actions_prefix_cancellation_bindings_and_insert_literals_are_independent() { let mut editor = editor(); editor .apply(vec![Effect::Replace("a\nb\nc\nd".into())]) .unwrap(); editor.handle(Input::Ctrl('e')); let carets = editor.carets(); type_text(&mut editor, "2d"); editor.handle(Input::Escape); assert_eq!(editor.carets(), carets); assert_eq!(editor.context().text, "a\nb\nc\nd"); editor.handle(Input::Escape); type_text(&mut editor, "gtdj"); assert_eq!(editor.cursor().row, 1); editor.bind("normal", "d", None).unwrap(); type_text(&mut editor, "dd"); assert_eq!(editor.context().text, "a\nb\nc\nd"); editor .bind("normal", "z", Some("core.delete_line".into())) .unwrap(); type_text(&mut editor, "2z"); assert_eq!(editor.context().text, "a\nd"); editor .bind("normal", "q", Some("core.change_line".into())) .unwrap(); type_text(&mut editor, "qccdd"); editor.handle(Input::Escape); assert_eq!(editor.context().text, "a\nccdd"); } fn project_editor(directory: &Directory) -> Editor { let mut editor = editor(); editor.project_root = directory.0.clone(); editor } #[test] fn jump_history_follows_links_and_restores_primary_grapheme_locations() { let directory = Directory::new(); let source = directory.path("source.md"); let target = directory.path("target.md"); fs::write(&source, "intro\n[go](target.md#destination)").unwrap(); fs::write(&target, "intro\n\n# Destination").unwrap(); let mut editor = Editor::open(Some(source), vec![]).unwrap(); type_text(&mut editor, "jl"); let departure = editor.cursor(); let source_id = editor.buffer_id(); type_text(&mut editor, "gf"); let target_id = editor.buffer_id(); assert_ne!(source_id, target_id); assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 }); assert_eq!(editor.jumps.len(), 2); fs::remove_file(target).unwrap(); editor.handle(Input::Ctrl('O')); assert_eq!(editor.buffer_id(), source_id); assert_eq!(editor.cursor(), departure); editor.handle(Input::Ctrl('I')); assert_eq!(editor.buffer_id(), target_id); assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 }); assert_eq!(editor.lines(), ["intro", "", "# Destination"]); assert_eq!(editor.buffer_count(), 2); } #[test] fn jump_history_records_same_file_fragments_once() { for link in ["#destination", "source.md#destination"] { let directory = Directory::new(); let source = directory.path("source.md"); fs::write(&source, format!("[go]({link})\n\n# Destination")).unwrap(); let mut editor = Editor::open(Some(source), vec![]).unwrap(); type_text(&mut editor, "gf"); assert_eq!(editor.buffer_count(), 1); assert_eq!(editor.cursor().row, 2); editor.handle(Input::Ctrl('o')); assert_eq!(editor.cursor(), Cursor::default()); let targets = editor.active.targets.clone(); editor.handle(Input::Ctrl('o')); assert_eq!(editor.active.targets, targets); editor.handle(Input::Char('\t')); assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 }); } } #[test] fn jump_history_records_edit_buffer_neighbors_and_picker_transactions() { let directory = Directory::new(); let a = directory.path("a.txt"); let b = directory.path("b.txt"); fs::write(&a, "alpha").unwrap(); fs::write(&b, "bravo").unwrap(); fs::write(directory.path("c.txt"), "charlie").unwrap(); let mut editor = Editor::open(Some(a), vec![]).unwrap(); editor.project_root = directory.0.clone(); type_text(&mut editor, "ll"); editor.execute(&format!("edit {}", b.display())); assert_eq!(editor.jumps.len(), 2); editor.execute("bnext"); assert_eq!(editor.buffer_id(), 1); assert_eq!(editor.cursor().column, 2); editor.execute("bprev"); assert_eq!(editor.buffer_id(), 2); editor.execute("buffer 1"); assert_eq!(editor.jumps.len(), 5); editor.execute("buffer 1"); assert_eq!(editor.jumps.len(), 5); editor.set_file_picker_view("list").unwrap(); type_text(&mut editor, " oc.txt\n"); assert_eq!(editor.buffer_id(), 3); assert_eq!(editor.jumps.len(), 6); editor.handle(Input::Ctrl('o')); assert_eq!(editor.buffer_id(), 1); assert_eq!(editor.cursor().column, 2); type_text(&mut editor, "3"); editor.handle(Input::Ctrl('o')); assert_eq!(editor.buffer_id(), 2); type_text(&mut editor, "4"); editor.handle(Input::Ctrl('i')); assert_eq!(editor.buffer_id(), 3); } #[test] fn jump_history_records_search_without_losing_query_or_highlight_invariants() { let mut editor = editor(); editor .apply(vec![Effect::Replace("hit\nδΈ­πŸ‘©β€πŸ’» hit\nhit".into())]) .unwrap(); type_text(&mut editor, "/hit\n"); assert_eq!(editor.cursor(), Cursor { row: 1, column: 3 }); type_text(&mut editor, "nN"); assert_eq!(editor.jumps.len(), 4); editor.handle(Input::Right); let departure = editor.cursor(); editor.handle(Input::Ctrl('o')); assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 }); assert!(editor.search_match().is_none()); assert_eq!(editor.active.search_query, "hit"); editor.handle(Input::Ctrl('i')); assert_eq!(editor.cursor(), departure); type_text(&mut editor, "2N"); assert_eq!(editor.cursor(), Cursor::default()); } #[test] fn jump_history_saves_roaming_departures_in_both_directions() { let directory = Directory::new(); let a = directory.path("a.txt"); let b = directory.path("b.txt"); fs::write(&a, "abcdef").unwrap(); fs::write(&b, "first\nabcdef").unwrap(); let mut editor = Editor::open(Some(a), vec![]).unwrap(); editor .navigate_to(b, TextPosition { row: 1, column: 0 }) .unwrap(); type_text(&mut editor, "ll"); editor.handle(Input::Ctrl('o')); assert_eq!(editor.buffer_id(), 1); type_text(&mut editor, "lll"); editor.handle(Input::Ctrl('i')); assert_eq!(editor.buffer_id(), 2); assert_eq!(editor.cursor(), Cursor { row: 1, column: 2 }); editor.handle(Input::Ctrl('o')); assert_eq!(editor.cursor(), Cursor { row: 0, column: 3 }); } #[test] fn jump_history_branches_only_after_successful_distinct_jumps() { let directory = Directory::new(); let path = directory.path("text.txt"); fs::write(&path, "zero\none\ntwo\nthree").unwrap(); let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); for row in [1, 2] { editor .navigate_to(path.clone(), TextPosition { row, column: 0 }) .unwrap(); } editor.handle(Input::Ctrl('o')); editor .navigate_to(path.clone(), TextPosition { row: 1, column: 0 }) .unwrap(); editor .navigate_to( directory.path("missing"), TextPosition { row: 0, column: 0 }, ) .unwrap_err(); editor.execute("buffer 999"); editor.execute("edit"); editor.execute("follow_link"); type_text(&mut editor, "/missing\n"); editor.handle(Input::Ctrl('i')); assert_eq!(editor.cursor().row, 2); editor.handle(Input::Ctrl('o')); editor.handle(Input::Right); editor .navigate_to(path, TextPosition { row: 3, column: 0 }) .unwrap(); editor.handle(Input::Ctrl('i')); assert_eq!(editor.cursor().row, 3); editor.handle(Input::Ctrl('o')); assert_eq!(editor.cursor(), Cursor { row: 1, column: 1 }); } #[test] fn jump_history_is_bounded_and_counts_stop_at_available_locations() { let directory = Directory::new(); let path = directory.path("text.txt"); fs::write( &path, (0..=120) .map(|n| n.to_string()) .collect::>() .join("\n"), ) .unwrap(); let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); for row in 1..=120 { editor .navigate_to(path.clone(), TextPosition { row, column: 0 }) .unwrap(); } type_text(&mut editor, "2"); editor.handle(Input::Ctrl('o')); assert_eq!(editor.cursor().row, 118); type_text(&mut editor, &usize::MAX.to_string()); editor.handle(Input::Ctrl('o')); assert_eq!(editor.cursor().row, 21); let targets = editor.active.targets.clone(); let revision = editor.text_revision(); editor.handle(Input::Ctrl('o')); assert_eq!(editor.active.targets, targets); assert_eq!(editor.text_revision(), revision); type_text(&mut editor, &usize::MAX.to_string()); editor.handle(Input::Ctrl('i')); assert_eq!(editor.cursor().row, 120); } #[test] fn jump_history_skips_closed_buffers_without_reopening_paths() { let directory = Directory::new(); let mut editor = editor(); for name in ["b", "c"] { let path = directory.path(name); fs::write(&path, name).unwrap(); editor.execute(&format!("edit {}", path.display())); } editor.execute("bdelete 2"); editor.handle(Input::Ctrl('o')); assert_eq!(editor.buffer_id(), 1); editor.handle(Input::Ctrl('i')); assert_eq!(editor.buffer_id(), 3); assert_eq!(editor.buffer_count(), 2); editor.execute("bdelete"); let targets = editor.active.targets.clone(); editor.handle(Input::Ctrl('o')); assert_eq!(editor.buffer_id(), 1); assert_eq!(editor.buffer_count(), 1); assert_eq!(editor.active.targets, targets); } #[test] fn jump_history_clamps_edited_locations_and_preserves_dirty_text_and_undo() { let directory = Directory::new(); let a = directory.path("a.txt"); let b = directory.path("b.txt"); fs::write(&a, "original\nabcdef").unwrap(); fs::write(&b, "other").unwrap(); let mut editor = Editor::open(Some(a), vec![]).unwrap(); type_text(&mut editor, "jlllll"); editor .navigate_to(b, TextPosition { row: 0, column: 0 }) .unwrap(); editor.parked.get_mut(&1).unwrap().lines = vec!["δΈ­πŸ‘©β€πŸ’»".into()]; editor.parked.get_mut(&1).unwrap().text_changed(); editor.handle(Input::Ctrl('o')); assert_eq!(editor.cursor(), Cursor { row: 0, column: 1 }); assert!(editor.dirty()); type_text(&mut editor, "iX"); editor.handle(Input::Escape); let text = editor.context().text; let undo = editor.active.undo.len(); let revision = editor.text_revision(); editor.handle(Input::Ctrl('i')); editor.handle(Input::Ctrl('o')); assert_eq!(editor.context().text, text); assert_eq!(editor.active.undo.len(), undo); assert_eq!(editor.text_revision(), revision); assert!(editor.dirty()); type_text(&mut editor, "u"); assert_eq!(editor.context().text, "δΈ­πŸ‘©β€πŸ’»"); let redo = editor.active.redo.len(); editor.handle(Input::Ctrl('i')); editor.handle(Input::Ctrl('o')); assert_eq!(editor.active.redo.len(), redo); type_text(&mut editor, "U"); assert_eq!(editor.context().text, text); } #[test] fn jump_history_skips_entries_clamped_to_the_current_location() { let directory = Directory::new(); let path = directory.path("text.txt"); fs::write(&path, "zero\none\ntwo").unwrap(); let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); for row in [1, 2] { editor .navigate_to(path.clone(), TextPosition { row, column: 0 }) .unwrap(); } editor.apply(vec![Effect::Replace("x".into())]).unwrap(); let targets = editor.active.targets.clone(); editor.handle(Input::Ctrl('o')); assert_eq!(editor.active.targets, targets); assert_eq!(editor.cursor(), Cursor::default()); } #[test] fn jump_history_counts_revisit_older_entries_at_the_same_coordinates() { let directory = Directory::new(); let path = directory.path("text.txt"); fs::write(&path, "zero\none").unwrap(); let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); for row in [1, 0] { editor .navigate_to(path.clone(), TextPosition { row, column: 0 }) .unwrap(); } type_text(&mut editor, "2"); editor.handle(Input::Ctrl('o')); assert_eq!(editor.cursor().row, 0); editor.handle(Input::Ctrl('i')); assert_eq!(editor.cursor().row, 1); } #[test] fn failed_jump_traversal_preserves_multiple_carets_search_and_edit_state() { let mut editor = editor(); editor .apply(vec![Effect::Replace("hit\nhit".into())]) .unwrap(); type_text(&mut editor, "/hit\n"); editor.handle(Input::Ctrl('k')); assert_eq!(editor.carets().len(), 2); let targets = editor.active.targets.clone(); let primary = editor.active.primary; let search = editor.search_match(); let undo = editor.active.undo.len(); let revision = editor.text_revision(); let timeline = editor.jumps.clone(); for input in [Input::Ctrl('i'), Input::Ctrl('o')] { editor.handle(input); assert_eq!(editor.active.targets, targets); assert_eq!(editor.active.primary, primary); assert_eq!(editor.search_match(), search); assert_eq!(editor.active.search_query, "hit"); assert_eq!(editor.active.undo.len(), undo); assert_eq!(editor.text_revision(), revision); assert_eq!(editor.jumps, timeline); assert_eq!(editor.mode(), Mode::Normal); assert!(editor.dirty()); } } #[test] fn ordinary_motions_edits_and_failed_jumps_do_not_seed_history() { let mut editor = editor(); editor .apply(vec![Effect::Replace("alpha beta".into())]) .unwrap(); type_text(&mut editor, "wlhiX"); editor.handle(Input::Escape); type_text(&mut editor, "uU"); editor.execute("follow_link"); editor.execute("bnext"); editor.execute("buffer 999"); let targets = editor.active.targets.clone(); let revision = editor.text_revision(); editor.handle(Input::Ctrl('o')); editor.handle(Input::Ctrl('i')); assert_eq!(editor.active.targets, targets); assert_eq!(editor.text_revision(), revision); } #[test] fn jump_keys_honor_control_aliases_custom_bindings_and_normal_mode_eligibility() { let directory = Directory::new(); let path = directory.path("text.txt"); fs::write(&path, "zero\none\ntwo").unwrap(); let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); editor .navigate_to(path, TextPosition { row: 1, column: 0 }) .unwrap(); editor .bind("normal", "ctrl+o", Some("core.right".into())) .unwrap(); editor.handle(Input::Ctrl('o')); assert_eq!(editor.cursor(), Cursor { row: 1, column: 1 }); editor .bind("normal", "leader [", Some("core.jump_back".into())) .unwrap(); type_text(&mut editor, " ["); assert_eq!(editor.cursor(), Cursor::default()); editor .bind("normal", "ctrl+i", Some("core.right".into())) .unwrap(); editor.handle(Input::Char('\t')); assert_eq!(editor.cursor().column, 1); editor .bind("normal", "tab", Some("core.jump_forward".into())) .unwrap(); editor.handle(Input::Ctrl('I')); assert_eq!(editor.cursor(), Cursor { row: 1, column: 1 }); for mode in ["insert", "visual", "command", "search"] { for action in ["core.jump_back", "core.jump_forward"] { assert!(editor.bind(mode, "z", Some(action.into())).is_err()); } } editor.handle(Input::Char('i')); editor.handle(Input::Ctrl('i')); editor.handle(Input::Char('\t')); assert_eq!(editor.lines()[1], "o\t\tne"); assert_eq!(editor.mode(), Mode::Insert); editor.handle(Input::Ctrl('p')); for action in ["core.jump_back", "core.jump_forward"] { assert!(!palette_entry(&editor, action).enabled); } editor.handle(Input::Escape); editor.handle(Input::Escape); editor.handle(Input::Ctrl('p')); assert!(palette_entry(&editor, "core.jump_back").enabled); assert!(palette_entry(&editor, "core.jump_forward").enabled); assert!(!palette_entry(&editor, "core.jump_back").bindings.is_empty()); } #[test] fn follow_link_resolves_relative_paths_from_source_not_cwd_or_project_root() { let directory = Directory::new(); let nested = directory.path("notes"); fs::create_dir(&nested).unwrap(); let source = nested.join("source.md"); let target = directory.path("Target Name.MD"); fs::write(&source, "[go](../Target%20Name.MD#target-heading)").unwrap(); fs::write(&target, "intro\n\n# Target heading\nbody").unwrap(); let mut editor = Editor::open(Some(source), vec![]).unwrap(); assert_ne!(std::env::current_dir().unwrap(), nested); editor.handle(Input::Char('g')); assert!(editor.g_pending()); editor.handle(Input::Char('f')); assert!(!editor.g_pending()); assert_eq!(editor.buffer_id(), 2); assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 }); assert_eq!(editor.context().text, "intro\n\n# Target heading\nbody"); assert_eq!(editor.buffer_count(), 2); } #[test] fn follow_link_reference_and_duplicate_heading_use_native_ex_command() { let directory = Directory::new(); let source = directory.path("source.md"); let target = directory.path("target.md"); fs::write( &source, "[go][section]\n\n[section]: target.md#hello-world-1", ) .unwrap(); fs::write( &target, "# Hello [world](ignored.md)\n\nHello *world*\n=============\n", ) .unwrap(); let mut editor = Editor::open(Some(source), vec![]).unwrap(); type_text(&mut editor, ":follow_link\n"); assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 }); assert_eq!(editor.lines()[0], "# Hello [world](ignored.md)"); } #[test] fn follow_link_reuses_dirty_target_even_after_disk_file_disappears() { let directory = Directory::new(); let source = directory.path("source.md"); let target = directory.path("target.md"); fs::write(&source, "[go](target.md#dirty-heading)").unwrap(); fs::write(&target, "# Disk heading").unwrap(); let mut editor = Editor::open(Some(source), vec![]).unwrap(); editor.execute(&format!("edit {}", target.display())); let target_id = editor.buffer_id(); editor .apply(vec![Effect::Replace("unsaved\n\n# Dirty heading".into())]) .unwrap(); let revision = editor.text_revision(); editor.execute("buffer 1"); fs::remove_file(&target).unwrap(); editor .bind("normal", "z", Some("core.follow_link".into())) .unwrap(); assert_eq!(editor.handle(Input::Char('z')), None); assert_eq!(editor.buffer_id(), target_id); assert_eq!(editor.buffer_count(), 2); assert_eq!(editor.context().text, "unsaved\n\n# Dirty heading"); assert_eq!(editor.text_revision(), revision); assert!(editor.dirty()); assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 }); } #[test] fn follow_link_failures_leave_source_carets_selections_text_and_buffer_ids_untouched() { let directory = Directory::new(); fs::write(directory.path("target.md"), "# Existing").unwrap(); for (filename, text, command) in [ ("source.md", "[x](missing.md)", "follow_link"), ("source.md", "[x](target.md#missing)", "follow_link"), ("source.md", "[x](#missing)", "follow_link"), ("source.md", "[missing][undefined]", "follow_link"), ("source.md", "[x](https://example.com)", "follow_link"), ("source.md", "not a link", "follow_link"), ("source.txt", "[x](target.md)", "follow_link"), ("source.md", "[x](target.md)", "follow_link extra"), ] { let source = directory.path(filename); fs::write(&source, text).unwrap(); let mut editor = Editor::open(Some(source), vec![]).unwrap(); type_text(&mut editor, "vl"); let carets = editor.carets(); let selections = editor.selections(); let revision = editor.text_revision(); assert_eq!(editor.execute(command), None); assert_eq!(editor.mode(), Mode::Visual); assert_eq!(editor.buffer_id(), 1); assert_eq!(editor.buffer_count(), 1); assert_eq!(editor.carets(), carets); assert_eq!(editor.selections(), selections); assert_eq!(editor.context().text, text); assert_eq!(editor.text_revision(), revision); assert!(!editor.message().is_empty()); if command == "follow_link" { editor.handle(Input::Escape); let carets = editor.carets(); type_text(&mut editor, "gf"); assert_eq!(editor.buffer_id(), 1); assert_eq!(editor.buffer_count(), 1); assert_eq!(editor.carets(), carets); assert_eq!(editor.context().text, text); } editor.execute(&format!("edit {}", directory.path("target.md").display())); assert_eq!(editor.buffer_id(), 2); } } #[test] fn follow_link_same_document_fragments_work_without_named_buffer() { let mut editor = editor(); editor .apply(vec![Effect::Replace( "[go](#hello-world)\n\n# Hello **world**\n".into(), )]) .unwrap(); let revision = editor.text_revision(); editor .bind("normal", "leader f", Some("core.follow_link".into())) .unwrap(); type_text(&mut editor, " f"); assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 }); assert_eq!(editor.buffer_count(), 1); assert_eq!(editor.path(), None); assert_eq!(editor.text_revision(), revision); assert_eq!( editor.context().text, "[go](#hello-world)\n\n# Hello **world**\n" ); editor.handle(Input::Home); editor.handle(Input::Up); editor.handle(Input::Up); editor .apply(vec![Effect::Replace("[go](target.md)".into())]) .unwrap(); editor.execute("follow_link"); assert_eq!(editor.buffer_count(), 1); assert_eq!(editor.cursor(), Cursor::default()); assert!(editor.message().contains("named")); } #[test] fn follow_link_explicit_same_file_path_reuses_current_dirty_buffer() { let directory = Directory::new(); let source = directory.path("source.md"); fs::write(&source, "# Old disk heading").unwrap(); let mut editor = Editor::open(Some(source), vec![]).unwrap(); let text = "[go](source.md#dirty-heading)\n\n# Dirty heading"; editor.apply(vec![Effect::Replace(text.into())]).unwrap(); let revision = editor.text_revision(); type_text(&mut editor, "gf"); assert_eq!(editor.buffer_id(), 1); assert_eq!(editor.buffer_count(), 1); assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 }); assert_eq!(editor.context().text, text); assert_eq!(editor.text_revision(), revision); assert!(editor.dirty()); } #[test] fn follow_link_uses_only_primary_caret_once() { let directory = Directory::new(); let source = directory.path("source.md"); let target = directory.path("target.md"); fs::write(&source, "[bad](missing.md)\n[go](target.md)").unwrap(); fs::write(&target, "# Target").unwrap(); let mut editor = Editor::open(Some(source), vec![]).unwrap(); editor.handle(Input::Ctrl('e')); assert_eq!(editor.carets().len(), 2); assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 }); type_text(&mut editor, "gf"); assert_eq!(editor.buffer_id(), 2); assert_eq!(editor.buffer_count(), 2); assert_eq!(editor.carets(), vec![Cursor::default()]); assert_eq!(editor.lines(), ["# Target"]); } #[test] fn follow_link_accepts_nonmarkdown_targets_without_fragments() { let directory = Directory::new(); let source = directory.path("source.md"); let target = directory.path("main.rs"); fs::write(&source, "[code](main.rs)").unwrap(); fs::write(&target, "fn main() {}").unwrap(); let mut editor = Editor::open(Some(source), vec![]).unwrap(); type_text(&mut editor, "9gf"); assert_eq!(editor.cursor(), Cursor::default()); assert_eq!(editor.count(), None); assert_eq!(editor.buffer_count(), 2); assert_eq!(editor.lines(), ["fn main() {}"]); } #[test] fn document_navigation_moves_all_carets_to_first_nonblank_without_edit_history() { let mut editor = editor(); let text = " e\u{301}top\nmiddle\n\t η•Œbottom"; editor.apply(vec![Effect::Replace(text.into())]).unwrap(); type_text(&mut editor, "j"); editor.handle(Input::Ctrl('e')); assert_eq!(editor.selection_count(), 2); type_text(&mut editor, "gt"); assert_eq!(editor.carets(), vec![Cursor { row: 0, column: 2 }]); type_text(&mut editor, "gb"); assert_eq!(editor.cursor(), Cursor { row: 2, column: 2 }); assert_eq!(editor.context().text, text); type_text(&mut editor, "u"); assert_eq!(editor.context().text, ""); for (text, last_row) in [("", 0), ("\n", 1), ("top\n", 1)] { editor.apply(vec![Effect::Replace(text.into())]).unwrap(); type_text(&mut editor, "gb"); assert_eq!( editor.cursor(), Cursor { row: last_row, column: 0 } ); type_text(&mut editor, "gt"); assert_eq!(editor.cursor(), Cursor { row: 0, column: 0 }); assert_eq!(editor.context().text, text); } } #[test] fn document_navigation_extends_both_visual_shapes_and_escape_cancels_prefix_only() { for selector in ['v', 'V'] { let mut editor = editor(); editor .apply(vec![Effect::Replace(" top\nmiddle\n\t end".into())]) .unwrap(); type_text(&mut editor, "jl"); editor.handle(Input::Char(selector)); type_text(&mut editor, "gb"); assert_eq!(editor.cursor(), Cursor { row: 2, column: 2 }); assert_eq!(editor.visual_linewise(), selector == 'V'); assert_eq!( editor.selections(), vec![if selector == 'V' { Selection { start: Cursor { row: 1, column: 0 }, end: Cursor { row: 2, column: 5 }, } } else { Selection { start: Cursor { row: 1, column: 1 }, end: Cursor { row: 2, column: 3 }, } }] ); let selected = editor.selections(); type_text(&mut editor, "g"); editor.handle(Input::Escape); assert_eq!(editor.mode(), Mode::Visual); assert_eq!(editor.selections(), selected); type_text(&mut editor, "gt"); assert_eq!(editor.cursor(), Cursor { row: 0, column: 2 }); assert_eq!( editor.selections(), vec![if selector == 'V' { Selection { start: Cursor { row: 0, column: 0 }, end: Cursor { row: 2, column: 0 }, } } else { Selection { start: Cursor { row: 0, column: 2 }, end: Cursor { row: 1, column: 2 }, } }] ); assert_eq!(editor.context().text, " top\nmiddle\n\t end"); } } #[test] fn document_navigation_prefix_respects_bindings_and_literal_insert_input() { let mut editor = editor(); editor .apply(vec![Effect::Replace("top\nmiddle\nbottom".into())]) .unwrap(); type_text(&mut editor, "jv"); editor .bind("visual", "g", Some("core.right".into())) .unwrap(); type_text(&mut editor, "gb"); assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 }); assert_eq!(editor.mode(), Mode::Visual); editor.handle(Input::Escape); editor.bind("normal", "g", None).unwrap(); type_text(&mut editor, "gt"); assert_eq!(editor.cursor().row, 1); type_text(&mut editor, "igtgb"); editor.handle(Input::Escape); assert_eq!(editor.context().text, "top\ngtgbmiddle\nbottom"); } #[test] fn document_navigation_actions_remap_and_yield_to_g_as_leader() { let mut editor = editor(); editor .apply(vec![Effect::Replace("top\nmiddle\nbottom".into())]) .unwrap(); editor.set_leader("g").unwrap(); editor .bind("normal", "leader t", Some("core.document_end".into())) .unwrap(); type_text(&mut editor, "gt"); assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 }); editor .bind("normal", "h", Some("core.document_start".into())) .unwrap(); type_text(&mut editor, "h"); assert_eq!(editor.cursor(), Cursor { row: 0, column: 0 }); editor .bind("normal", "g", Some("core.document_end".into())) .unwrap(); type_text(&mut editor, "g"); assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 }); type_text(&mut editor, "igtgb"); editor.handle(Input::Escape); assert_eq!(editor.context().text, "top\nmiddle\ngtgbbottom"); } #[test] fn native_gf_prefix_yields_to_configured_g_commands_and_preserves_counts() { let mut editor = editor(); editor .apply(vec![Effect::Replace("a\nb\nc\nd".into())]) .unwrap(); editor.handle(Input::Ctrl('p')); assert_eq!( palette_entry(&editor, "core.follow_link").bindings, vec!["gf"] ); assert!(palette_entry(&editor, "core.follow_link").enabled); editor.handle(Input::Escape); editor .bind("normal", "g", Some("core.down".into())) .unwrap(); type_text(&mut editor, "2g"); assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 }); assert_eq!(editor.count(), None); assert!(!editor.g_pending()); editor.handle(Input::Ctrl('p')); assert!( palette_entry(&editor, "core.follow_link") .bindings .is_empty() ); editor.handle(Input::Escape); editor.bind("normal", "g", None).unwrap(); type_text(&mut editor, "g"); assert!(!editor.g_pending()); editor.register(plugin("fixture", &["greet"])).unwrap(); editor.bind("normal", "g", Some("greet".into())).unwrap(); type_text(&mut editor, "3"); assert_eq!( editor.handle(Input::Char('g')), Some(Invocation { plugin: "fixture".into(), command: "greet".into(), args: String::new(), }) ); assert_eq!(editor.count(), None); assert!(!editor.g_pending()); for mode in ["insert", "visual", "command", "search"] { assert!( editor .bind(mode, "z", Some("core.follow_link".into())) .is_err() ); } editor.handle(Input::Char('v')); editor.handle(Input::Ctrl('p')); assert!(!palette_entry(&editor, "core.follow_link").enabled); assert!( palette_entry(&editor, "core.follow_link") .bindings .is_empty() ); } #[test] fn native_g_prefix_cancels_on_escape_intervening_input_commands_and_lifecycle() { let directory = Directory::new(); let mut editor = editor(); editor .apply(vec![Effect::Replace("a\nb\nc".into())]) .unwrap(); type_text(&mut editor, "4g"); editor.handle(Input::Escape); assert!(!editor.g_pending()); assert_eq!(editor.count(), None); type_text(&mut editor, "4gj"); assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 }); assert!(!editor.g_pending()); type_text(&mut editor, "gi"); assert_eq!(editor.mode(), Mode::Insert); assert!(!editor.g_pending()); editor.handle(Input::Char('f')); editor.handle(Input::Escape); assert_eq!(editor.context().text, "a\nfb\nc"); for command in ["plugins", "bnext", "bprev", "buffer 1", "bdelete!"] { editor.handle(Input::Char('g')); assert!(editor.g_pending()); editor.execute(command); assert!(!editor.g_pending()); } editor.handle(Input::Char('g')); editor.handle(Input::Paste("f".into())); assert!(!editor.g_pending()); editor.handle(Input::Char('g')); editor .navigate_to( directory.path("missing.md"), TextPosition { row: 0, column: 0 }, ) .unwrap_err(); assert!(!editor.g_pending()); editor.handle(Input::Char('g')); editor .bind("normal", "z", Some("core.follow_link".into())) .unwrap(); assert!(!editor.g_pending()); editor.set_leader("g").unwrap(); editor.handle(Input::Char('g')); assert!(!editor.g_pending()); assert_eq!(editor.leader_pending(), Some("g")); editor.handle(Input::Escape); editor.handle(Input::Ctrl('p')); assert_eq!( palette_entry(&editor, "core.follow_link").bindings, vec!["z"] ); } #[test] fn semantic_snapshots_are_borrowed_buffer_local_and_invalidated_by_text_and_path() { use crate::plugin::SemanticTokenKind; let directory = Directory::new(); let a = directory.path("a.py"); let b = directory.path("b.py"); fs::write(&a, "e\u{301}η•Œ").unwrap(); fs::write(&b, "other").unwrap(); let mut editor = Editor::open(Some(a.clone()), vec![]).unwrap(); editor.set_clipboard_mode("separate").unwrap(); let tokens: Arc<[SemanticToken]> = Arc::from([SemanticToken { start: TextPosition { row: 0, column: 0 }, end: TextPosition { row: 0, column: 2 }, kind: SemanticTokenKind::Variable, modifiers: 1023, }]); assert!( editor .replace_semantic_tokens(1, 0, Some(tokens.clone())) .unwrap() ); assert!(std::ptr::eq(editor.semantic_tokens(), tokens.as_ref())); assert!(!editor.dirty()); assert!(editor.active.undo.is_empty()); editor.execute(&format!("edit {}", b.display())); assert!(editor.semantic_tokens().is_empty()); assert!( editor .replace_semantic_tokens(2, 0, Some(tokens.clone())) .unwrap() ); editor.execute("buffer 1"); assert!(std::ptr::eq(editor.semantic_tokens(), tokens.as_ref())); assert!( !editor .replace_semantic_tokens(1, 0, Some(tokens.clone())) .unwrap() ); editor.execute("w"); assert_eq!(editor.semantic_tokens(), tokens.as_ref()); editor.execute(&format!("w {}", directory.path("renamed.py").display())); assert_eq!(editor.text_revision(), 0); assert!(editor.semantic_tokens().is_empty()); assert!( editor .replace_semantic_tokens(1, 0, Some(tokens.clone())) .unwrap() ); editor.handle(Input::Char('x')); assert!(editor.semantic_tokens().is_empty()); editor.handle(Input::Char('u')); assert_eq!(editor.lines(), ["e\u{301}η•Œ"]); assert!(editor.semantic_tokens().is_empty()); assert!( !editor .replace_semantic_tokens(1, 0, Some(tokens.clone())) .unwrap() ); editor.execute("buffer 2"); assert_eq!(editor.semantic_tokens(), tokens.as_ref()); editor.clear_semantic_tokens(); assert!(editor.semantic_tokens().is_empty()); editor.execute("buffer 1"); assert!(editor.semantic_tokens().is_empty()); } #[test] fn semantic_validation_rejects_malformed_live_tokens_but_ignores_stale_and_unloaded() { use crate::plugin::SemanticTokenKind; let mut editor = editor(); editor .apply(vec![Effect::Replace("e\u{301}η•Œ\nx".into())]) .unwrap(); let revision = editor.text_revision(); let token = SemanticToken { start: TextPosition { row: 0, column: 0 }, end: TextPosition { row: 0, column: 2 }, kind: SemanticTokenKind::Variable, modifiers: 0, }; let invalid = [ SemanticToken { end: TextPosition { row: 0, column: 3 }, ..token }, SemanticToken { end: token.start, ..token }, SemanticToken { end: TextPosition { row: 1, column: 1 }, ..token }, SemanticToken { start: TextPosition { row: 9, column: 0 }, end: TextPosition { row: 9, column: 1 }, ..token }, SemanticToken { modifiers: 1024, ..token }, ]; for token in invalid { assert!( editor .validate_semantic_tokens(1, revision, Some(&[token])) .is_err() ); assert!( !editor .validate_semantic_tokens(1, revision - 1, Some(&[token])) .unwrap() ); assert!( !editor .validate_semantic_tokens(999, revision, Some(&[token])) .unwrap() ); } let later = SemanticToken { start: TextPosition { row: 0, column: 1 }, ..token }; assert!( editor .validate_semantic_tokens(1, revision, Some(&[later, token])) .is_err() ); // Raw adjacent LSP tokens may map outward to the same grapheme. assert!( editor .validate_semantic_tokens(1, revision, Some(&[token, token, later])) .unwrap() ); let oversized = vec![token; MAX_EDIT_BYTES / std::mem::size_of::() + 1]; assert!( editor .validate_semantic_tokens(1, revision, Some(&oversized)) .is_err() ); let tokens: Arc<[SemanticToken]> = Arc::from([token]); editor .replace_semantic_tokens(1, revision, Some(tokens)) .unwrap(); assert!(editor.replace_semantic_tokens(1, revision, None).unwrap()); assert!(editor.semantic_tokens().is_empty()); } #[test] fn semantic_column_bounds_preserve_ascii_tabs_and_unicode_graphemes() { use crate::plugin::SemanticTokenKind; for text in ["a\tb", "e\u{301}η•ŒπŸ‘©β€πŸš€"] { let mut editor = editor(); editor.apply(vec![Effect::Replace(text.into())]).unwrap(); let token = SemanticToken { start: TextPosition { row: 0, column: 0 }, end: TextPosition { row: 0, column: 3 }, kind: SemanticTokenKind::Variable, modifiers: 0, }; editor .replace_semantic_tokens(1, editor.text_revision(), Some(Arc::from([token]))) .unwrap(); let outside = SemanticToken { end: TextPosition { row: 0, column: 4 }, ..token }; assert!( editor .replace_semantic_tokens(1, editor.text_revision(), Some(Arc::from([outside]))) .is_err(), "{text:?}", ); assert_eq!(editor.semantic_tokens(), &[token]); } } #[test] fn hover_scroll_and_escape_preserve_carets_and_edits_dismiss_normally() { let mut editor = editor(); editor .apply(vec![Effect::Replace("alpha\nbeta".into())]) .unwrap(); set_carets(&mut editor, &[(0, 1), (1, 2)], 1); let targets = editor.active.targets.clone(); let primary = editor.active.primary; let text = editor.context().text; let revision = editor.text_revision(); let history = (editor.active.undo.len(), editor.active.redo.len()); for command in [ ":lsp_hover", ":lsp_definition", ":lsp_diagnostics", ":lsp.hover unexpected extra", ":lsp.definition unexpected extra", ":lsp.diagnostics unexpected extra", ] { assert!(editor.execute(command).is_none(), "{command}"); } editor .bind("normal", "K", Some("core.lsp.hover".into())) .unwrap(); type_text(&mut editor, "9"); assert_eq!( editor.handle(Input::Char('K')), Some(Invocation { plugin: "core".into(), command: "lsp.hover".into(), args: String::new(), }) ); assert_eq!(editor.count(), None); editor .show_hover("η•Œe\u{301}πŸ‘©β€πŸš€\tend\r\nsecond\nlast".into()) .unwrap(); assert!(editor.show_hover("\u{1b}[31m".into()).is_err()); assert_eq!(editor.hover().unwrap().lines().nth(1), Some("second")); for input in [ Input::Down, Input::Ctrl('j'), Input::Down, Input::Right, Input::Ctrl('l'), ] { assert_eq!(editor.handle(input), None); } assert_eq!( (editor.hover().unwrap().top, editor.hover().unwrap().left), (2, 2) ); editor.handle(Input::Escape); assert!(editor.hover().is_none()); assert_eq!(editor.active.targets, targets); assert_eq!(editor.active.primary, primary); assert_eq!(editor.context().text, text); assert_eq!(editor.text_revision(), revision); assert_eq!( (editor.active.undo.len(), editor.active.redo.len()), history ); assert_eq!(editor.mode(), Mode::Normal); editor.handle(Input::Escape); assert_eq!(editor.selection_count(), 1); editor.show_hover("documentation".into()).unwrap(); editor.handle(Input::Char('i')); assert!(editor.hover().is_none()); assert_eq!(editor.mode(), Mode::Insert); editor.handle(Input::Char('X')); editor.handle(Input::Escape); assert_eq!(editor.context().text, "alpha\nbeXta"); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, text); editor.show_hover("documentation".into()).unwrap(); editor.apply(vec![Effect::Replace("new".into())]).unwrap(); assert!(editor.hover().is_none()); } #[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(); } } } #[test] fn line_actions_clipboard_failure_preserves_carets_history_and_repeat() { let fixture = Fixture::new(); fixture.external("keep"); let mut editor = fixture.editor(); editor .apply(vec![Effect::Replace("one\ntwo\nthree".into())]) .unwrap(); type_text(&mut editor, "iX"); editor.handle(Input::Escape); type_text(&mut editor, "l"); editor.handle(Input::Ctrl('e')); let carets = editor.carets(); let revision = editor.text_revision(); fixture.fail("fail-write", true); for keys in ["2dd", "cc"] { type_text(&mut editor, keys); assert_eq!(editor.context().text, "Xone\ntwo\nthree"); assert_eq!(editor.carets(), carets); assert_eq!(editor.mode(), Mode::Normal); assert_eq!(editor.text_revision(), revision); assert_eq!(fixture.bytes("state"), b"keep"); } type_text(&mut editor, "u"); assert_eq!(editor.context().text, "one\ntwo\nthree"); type_text(&mut editor, "U."); assert_eq!(editor.context().text, "XXone\ntXwo\nthree"); } // 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.linewise, editor.active.text_revision, &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.clipboard_linewise, &editor.owned_system_clipboard, &editor.last_edit, editor.clipboard_mode, ), ), ) } #[test] fn linewise_owned_system_bytes_keep_shape_external_changes_do_not_guess_it() { let fixture = Fixture::new(); let mut editor = fixture.editor(); editor.apply(vec![Effect::Replace("row".into())]).unwrap(); type_text(&mut editor, "Vy"); assert_eq!(fixture.bytes("state"), b"row\n"); editor.apply(vec![Effect::Replace("X".into())]).unwrap(); editor.handle(Input::Char('P')); assert_eq!(editor.context().text, "row\nX"); fixture.external("row\n"); editor.apply(vec![Effect::Replace("YZ".into())]).unwrap(); editor.handle(Input::Char('p')); assert_eq!(editor.context().text, "YZ\nrow"); fixture.external("other\n"); editor.apply(vec![Effect::Replace("YZ".into())]).unwrap(); editor.handle(Input::Char('p')); assert_eq!(editor.context().text, "Yother\nZ"); assert!(editor.owned_system_clipboard.is_none()); editor.handle(Input::Char('u')); fixture.external("row\n"); editor.handle(Input::Char('p')); assert_eq!(editor.context().text, "Yrow\nZ"); } #[test] fn linewise_empty_final_row_publication_failures_leave_selection_unchanged() { for key in ['y', 'd', 'c'] { let fixture = Fixture::new(); let mut editor = fixture.editor(); editor.apply(vec![Effect::Replace("a\n".into())]).unwrap(); type_text(&mut editor, "jV"); fixture.fail("fail-write", true); let before = state(&editor); editor.handle(Input::Char(key)); assert_eq!(state(&editor), before); assert!(editor.visual_linewise()); assert_eq!(fixture.bytes("state"), b""); fixture.fail("fail-write", false); editor.handle(Input::Char(key)); assert_eq!(fixture.bytes("state"), b"\n"); assert_eq!(editor.context().text, if key == 'd' { "a" } else { "a\n" }); } } #[test] fn linewise_repeat_publication_failure_restores_shape_and_recipe_atomically() { let fixture = Fixture::new(); let mut editor = fixture.editor(); editor .apply(vec![Effect::Replace("a\nb\nc".into())]) .unwrap(); type_text(&mut editor, "Vd"); editor .apply(vec![Effect::Replace("aa\nbb\ncc".into())]) .unwrap(); type_text(&mut editor, "Vj"); fixture.fail("fail-write", true); let before = state(&editor); editor.handle(Input::Char('.')); assert_eq!(state(&editor), before); assert_eq!(fixture.bytes("state"), b"a\n"); assert!(editor.visual_linewise()); fixture.fail("fail-write", false); editor.handle(Input::Char('.')); assert_eq!(editor.context().text, "cc"); assert_eq!(fixture.bytes("state"), b"aa\nbb\n"); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "aa\nbb\ncc"); assert_eq!(editor.mode(), Mode::Normal); } #[test] fn linewise_internal_shape_is_independent_of_owned_system_shape() { 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, "Vy"); editor .bind("visual", "q", Some("core.copy_system".into())) .unwrap(); editor.apply(vec![Effect::Replace("out".into())]).unwrap(); type_text(&mut editor, "veq"); assert_eq!(fixture.bytes("state"), b"out"); editor.apply(vec![Effect::Replace("XY".into())]).unwrap(); editor.handle(Input::Char('p')); assert_eq!(editor.context().text, "XY\ninside"); editor.apply(vec![Effect::Replace("XY".into())]).unwrap(); editor.set_clipboard_mode("system").unwrap(); editor.handle(Input::Char('p')); assert_eq!(editor.context().text, "XoutY"); } #[test] fn linewise_dot_paste_keeps_captured_shape_after_metadata_loss_and_read_failure() { let fixture = Fixture::new(); let mut editor = fixture.editor(); editor.apply(vec![Effect::Replace("row".into())]).unwrap(); type_text(&mut editor, "Vyp"); assert_eq!(editor.context().text, "row\nrow"); editor.apply(vec![Effect::Replace("AB".into())]).unwrap(); fixture.fail("fail-read", true); let before = state(&editor); editor.handle(Input::Char('p')); assert_eq!(state(&editor), before); fixture.fail("fail-read", false); fixture.external(""); editor.handle(Input::Char('p')); assert_eq!(editor.context().text, "AB"); assert!(editor.owned_system_clipboard.is_none()); let reads = fixture.bytes("reads").len(); fixture.fail("fail-read", true); editor.handle(Input::Char('.')); assert_eq!(editor.context().text, "AB\nrow"); assert_eq!(fixture.bytes("reads").len(), reads); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "AB"); } #[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, false) .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, false) .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, false) .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 linewise_selection_preserves_bidirectional_endpoints_and_selector_toggles() { let mut editor = editor(); editor .apply(vec![Effect::Replace("aη•Œz\nb\nccc\n".into())]) .unwrap(); type_text(&mut editor, "jjlVkh"); assert!(editor.visual_linewise()); assert_eq!(editor.mode(), Mode::Visual); assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 }); assert_eq!(editor.linewise_rows().collect::>(), [1..=2]); assert_eq!( editor.selections(), [Selection { start: Cursor { row: 1, column: 0 }, end: Cursor { row: 3, column: 0 }, }] ); editor.handle(Input::Char('v')); assert!(!editor.visual_linewise()); assert_eq!(editor.selections()[0].end, Cursor { row: 2, column: 2 }); editor.handle(Input::Char('V')); assert_eq!(editor.linewise_rows().collect::>(), [1..=2]); editor.handle(Input::Char('V')); assert_eq!(editor.mode(), Mode::Normal); assert!(!editor.visual_linewise()); type_text(&mut editor, "Vj"); assert_eq!(editor.linewise_rows().collect::>(), [1..=2]); editor.handle(Input::Escape); assert!(editor.linewise_rows().next().is_none()); type_text(&mut editor, "vv"); assert_eq!(editor.mode(), Mode::Normal); } #[test] fn linewise_parking_cloning_merging_and_row_insertions_preserve_primary() { let directory = Directory::new(); let other = directory.path("other"); fs::write(&other, "other").unwrap(); let mut editor = editor(); editor .apply(vec![Effect::Replace("abc\ndef\nghi\n".into())]) .unwrap(); type_text(&mut editor, "lV"); editor.handle(Input::Ctrl('e')); assert_eq!(editor.linewise_rows().collect::>(), [0..=0, 1..=1]); assert_eq!(editor.cursor(), Cursor { row: 1, column: 1 }); editor.execute(&format!("edit {}", other.display())); assert!(!editor.visual_linewise()); editor.execute("buffer 1"); assert!(editor.visual_linewise()); assert_eq!(editor.cursor(), Cursor { row: 1, column: 1 }); editor.handle(Input::Char('j')); assert_eq!(editor.selection_count(), 1); assert_eq!(editor.linewise_rows().collect::>(), [0..=2]); assert_eq!(editor.cursor(), Cursor { row: 2, column: 1 }); editor.handle(Input::Char('v')); assert_eq!(editor.selections()[0].start, Cursor { row: 0, column: 1 }); editor.handle(Input::Char('V')); editor.handle(Input::Char('A')); type_text(&mut editor, "!"); editor.handle(Input::Escape); assert_eq!(editor.context().text, "abc!\ndef!\nghi!\n"); assert_eq!(editor.selection_count(), 3); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "abc\ndef\nghi\n"); set_carets(&mut editor, &[(2, 0)], 0); type_text(&mut editor, "Vj"); editor.handle(Input::Ctrl('l')); assert_eq!( editor.carets(), [Cursor { row: 2, column: 0 }, Cursor { row: 3, column: 0 }] ); assert!(!editor.visual_linewise()); } #[test] fn linewise_paste_inserts_above_or_below_rows_across_eof_and_empty_rows() { for (text, row, key, expected) in [ ("x\ny", 0, 'p', "x\n a\n\ny"), ("x\ny", 0, 'P', " a\n\nx\ny"), ("x\ny", 1, 'p', "x\ny\n a\n"), ("x\ny", 1, 'P', "x\n a\n\ny"), ("x\n", 1, 'p', "x\n\n a\n"), ("x\n", 1, 'P', "x\n a\n\n"), ("", 0, 'p', "\n a\n"), ("", 0, 'P', " a\n\n"), ] { let mut editor = editor(); editor .apply(vec![Effect::Replace(" a\n\nlast".into())]) .unwrap(); type_text(&mut editor, "Vjy"); editor.apply(vec![Effect::Replace(text.into())]).unwrap(); set_carets(&mut editor, &[(row, 1)], 0); editor.handle(Input::Char(key)); assert_eq!(editor.context().text, expected, "{text:?} {key}"); let first = if key == 'p' { row + 1 } else { row }; assert_eq!( editor.cursor(), Cursor { row: first, column: 1 } ); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, text); assert_eq!(editor.mode(), Mode::Normal); editor.handle(Input::Char('U')); assert_eq!(editor.context().text, expected); } } #[test] fn linewise_empty_final_row_is_a_real_clipboard_row_and_cut_target() { let mut editor = editor(); editor.apply(vec![Effect::Replace("a\n".into())]).unwrap(); type_text(&mut editor, "jVy"); editor.apply(vec![Effect::Replace("z".into())]).unwrap(); editor.handle(Input::Char('p')); assert_eq!(editor.context().text, "z\n"); editor.handle(Input::Char('u')); editor.handle(Input::Char('P')); assert_eq!(editor.context().text, "\nz"); editor.apply(vec![Effect::Replace("a\n".into())]).unwrap(); type_text(&mut editor, "jVd"); assert_eq!(editor.context().text, "a"); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "a\n"); assert!(!editor.visual_linewise()); type_text(&mut editor, "VcQ"); editor.handle(Input::Escape); assert_eq!(editor.context().text, "a\nQ"); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "a\n"); } #[test] fn linewise_cut_and_change_handle_eof_all_rows_and_group_typing_history() { for (text, keys, expected) in [ ("a\nbb\nccc", "jVjd", "a"), ("a\nbb\nccc", "jVjcQ", "a\nQ"), ("a\nbb\nccc", "V2jd", ""), ("a\nbb\nccc", "V2jcQ", "Q"), ("a\nbb\n", "V2jd", ""), ("a\nbb\n", "V2jcQ", "Q"), ("a\nbb\nccc", "VjcQ", "Q\nccc"), ("", "VcQ", "Q"), ] { let mut editor = editor(); editor.apply(vec![Effect::Replace(text.into())]).unwrap(); type_text(&mut editor, keys); editor.handle(Input::Escape); assert_eq!(editor.context().text, expected, "{keys}"); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, text); assert_eq!(editor.mode(), Mode::Normal); assert!(!editor.visual_linewise()); editor.handle(Input::Char('U')); assert_eq!(editor.context().text, expected); } } #[test] fn linewise_multitarget_cut_change_and_paste_share_one_transaction() { let mut editor = editor(); editor .apply(vec![Effect::Replace("a\nbb\nccc".into())]) .unwrap(); set_carets(&mut editor, &[(1, 1), (2, 1)], 1); type_text(&mut editor, "Vd"); assert_eq!(editor.context().text, "a"); assert_eq!(editor.selection_count(), 1); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "a\nbb\nccc"); assert_eq!(editor.cursor().row, 2); type_text(&mut editor, "VcQ"); editor.handle(Input::Escape); assert_eq!(editor.context().text, "a\nQ\nQ"); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "a\nbb\nccc"); editor.apply(vec![Effect::Replace("x\ny".into())]).unwrap(); set_carets(&mut editor, &[(0, 0), (1, 0)], 1); editor.handle(Input::Char('P')); assert_eq!(editor.context().text, "bb\nx\nccc\ny"); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "x\ny"); set_carets(&mut editor, &[(0, 0)], 0); editor.handle(Input::Char('p')); assert_eq!(editor.context().text, "x\ny"); assert!(editor.message().contains("count")); } #[test] fn linewise_paste_merges_identical_same_row_targets_and_refuses_conflicts() { let mut editor = editor(); editor .apply(vec![Effect::Replace("one\ntwo".into())]) .unwrap(); set_carets(&mut editor, &[(0, 0), (1, 0)], 1); type_text(&mut editor, "Vy"); editor.apply(vec![Effect::Replace("abcd".into())]).unwrap(); set_carets(&mut editor, &[(0, 0), (0, 2)], 1); let carets = editor.carets(); editor.handle(Input::Char('P')); assert_eq!(editor.context().text, "abcd"); assert_eq!(editor.carets(), carets); assert!(editor.message().contains("Conflicting")); set_carets(&mut editor, &[(0, 0)], 0); type_text(&mut editor, "Vy"); set_carets(&mut editor, &[(0, 0), (0, 2)], 1); editor.handle(Input::Char('p')); assert_eq!(editor.context().text, "abcd\nabcd"); assert_eq!(editor.selection_count(), 1); assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 }); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "abcd"); assert_eq!(editor.carets(), carets); } #[test] fn linewise_counted_search_and_occurrences_keep_shape_rules() { let mut editor = editor(); editor .apply(vec![Effect::Replace("hit a\nz\nhit b\nz\nhit c\n".into())]) .unwrap(); submit_search(&mut editor, "hit"); assert_eq!(editor.cursor().row, 2); type_text(&mut editor, "Vn"); assert_eq!(editor.linewise_rows().collect::>(), [2..=4]); type_text(&mut editor, "2N"); assert_eq!(editor.linewise_rows().collect::>(), [0..=2]); assert_eq!(editor.cursor(), Cursor { row: 0, column: 0 }); editor.handle(Input::Escape); editor .apply(vec![Effect::Replace("same\nsame\nlast".into())]) .unwrap(); editor.handle(Input::Char('V')); editor.handle(Input::Ctrl('n')); assert_eq!(editor.mode(), Mode::Visual); assert!(!editor.visual_linewise()); assert_eq!(editor.selection_count(), 2); assert_eq!( editor.selections(), [ Selection { start: Cursor { row: 0, column: 0 }, end: Cursor { row: 1, column: 0 } }, Selection { start: Cursor { row: 1, column: 0 }, end: Cursor { row: 2, column: 0 } }, ] ); } #[test] fn linewise_dot_retargets_row_spans_and_captures_paste_shape() { let mut editor = editor(); editor .apply(vec![Effect::Replace("a\nbb\nccc\nlast".into())]) .unwrap(); type_text(&mut editor, "Vjd"); assert_eq!(editor.context().text, "ccc\nlast"); editor.handle(Input::Char('.')); assert_eq!(editor.context().text, ""); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "ccc\nlast"); editor .apply(vec![Effect::Replace("a\nb\nc\nd".into())]) .unwrap(); type_text(&mut editor, "jVkcX"); editor.handle(Input::Escape); assert_eq!(editor.context().text, "X\nc\nd"); type_text(&mut editor, "jj."); assert_eq!(editor.context().text, "X\nX"); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "X\nc\nd"); type_text(&mut editor, "V."); assert_eq!(editor.context().text, "X\nc\nX"); editor.apply(vec![Effect::Replace("row".into())]).unwrap(); type_text(&mut editor, "Vyp"); assert_eq!(editor.context().text, "row\nrow"); editor.handle(Input::Char('u')); type_text(&mut editor, "vy"); editor.handle(Input::Char('.')); assert_eq!(editor.context().text, "row\nrow"); } #[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()); for name in [ ".hover", "lsp.", "lsp..hover", "lsp.3hover", "lsp.hover!extra", ] { assert!( editor .register(plugin("invalid", &["newcmd", name])) .is_err() ); assert!(editor.bind("normal", "g", Some("newcmd".into())).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+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"); } fn completion_item(start: (usize, usize), end: (usize, usize), text: &str) -> CompletionItem { CompletionItem { label: text.into(), detail: None, primary_edit: 0, snippet: None, edits: vec![CompletionEdit { start: TextPosition { row: start.0, column: start.1, }, end: TextPosition { row: end.0, column: end.1, }, text: text.into(), }], } } #[test] fn completion_cycles_from_original_spans_and_restores_pending_typing() { let mut editor = editor(); type_text(&mut editor, "iabcd"); let revision = editor.text_revision(); let steps = editor.active.pending_edit.as_ref().unwrap().steps.len(); editor .show_completion(vec![ completion_item((0, 2), (0, 4), "X"), completion_item((0, 0), (0, 1), "Q\nZ"), ]) .unwrap(); assert_eq!(editor.context().text, "abX"); assert_eq!(editor.completion().unwrap().selected, 1); editor.handle(Input::Ctrl('n')); assert_eq!(editor.context().text, "Q\nZbcd"); assert_eq!(editor.cursor(), Cursor { row: 1, column: 1 }); editor.handle(Input::Ctrl('j')); assert_eq!(editor.context().text, "abcd"); assert_eq!(editor.cursor(), Cursor { row: 0, column: 4 }); assert_eq!(editor.completion().unwrap().selected, 0); assert_eq!( editor.active.pending_edit.as_ref().unwrap().steps.len(), steps ); assert!(editor.text_revision() > revision); editor.handle(Input::Ctrl('k')); assert_eq!(editor.context().text, "Q\nZbcd"); editor.handle(Input::Char('!')); assert!(editor.completion().is_none()); assert_eq!(editor.context().text, "Q\nZ!bcd"); editor.handle(Input::Escape); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, ""); } #[test] fn completion_same_span_noop_preview_and_original_preserve_redo() { let mut editor = editor(); type_text(&mut editor, "iold"); editor.handle(Input::Escape); editor.handle(Input::Char('u')); assert_eq!(editor.active.redo.len(), 1); editor.handle(Input::Char('i')); editor .show_completion(vec![ completion_item((0, 0), (0, 0), "one"), completion_item((0, 0), (0, 0), "one"), ]) .unwrap(); let revision = editor.text_revision(); editor.handle(Input::Ctrl('n')); assert_eq!(editor.context().text, "one"); assert_eq!(editor.text_revision(), revision); editor.handle(Input::Ctrl('n')); assert_eq!(editor.context().text, ""); editor.handle(Input::Escape); assert_eq!(editor.active.redo.len(), 1); editor.handle(Input::Char('U')); assert_eq!(editor.context().text, "old"); } #[test] fn completion_noncycle_keys_keep_standard_insert_semantics() { for (key, expected, mode) in [ (Input::Char('!'), "completed!", Mode::Insert), (Input::Enter, "completed\n", Mode::Insert), (Input::Char('\t'), "completed\t", Mode::Insert), (Input::Escape, "completed", Mode::Normal), (Input::Left, "completed", Mode::Insert), (Input::Ctrl('z'), "completed", Mode::Insert), ] { let mut editor = editor(); type_text(&mut editor, "iprefix"); editor .show_completion(vec![completion_item((0, 0), (0, 6), "completed")]) .unwrap(); editor.handle(key); assert!(editor.completion().is_none()); assert_eq!(editor.context().text, expected); assert_eq!(editor.mode(), mode); if mode == Mode::Insert { editor.handle(Input::Escape); } editor.handle(Input::Char('u')); assert_eq!(editor.context().text, ""); } } #[test] fn completion_request_and_cycle_use_effective_insert_binding() { let mut editor = editor(); type_text(&mut editor, "iab"); let steps = editor.active.pending_edit.as_ref().unwrap().steps.len(); assert_eq!( editor.handle(Input::Ctrl('n')), Some(Invocation { plugin: "core".into(), command: "lsp.completion".into(), args: String::new(), }) ); assert_eq!(editor.mode(), Mode::Insert); assert_eq!( editor.active.pending_edit.as_ref().unwrap().steps.len(), steps ); assert!(editor.active.undo.is_empty()); editor .show_completion(vec![ completion_item((0, 0), (0, 2), "first"), completion_item((0, 0), (0, 2), "second"), ]) .unwrap(); editor.bind("insert", "Ctrl+n", None).unwrap(); editor .bind("insert", "Ctrl+x", Some("core.lsp.completion".into())) .unwrap(); editor.handle(Input::Ctrl('x')); assert_eq!(editor.context().text, "second"); editor.handle(Input::Ctrl('n')); assert!(editor.completion().is_none()); assert_eq!(editor.context().text, "second"); } #[test] fn completion_dot_rebases_only_final_choice_and_refuses_out_of_bounds_atomically() { let mut editor = editor(); editor .apply(vec![Effect::Replace("left ab".into())]) .unwrap(); editor.handle(Input::Char('A')); editor .show_completion(vec![ completion_item((0, 5), (0, 7), "wrong"), completion_item((0, 5), (0, 7), "final"), ]) .unwrap(); editor.handle(Input::Ctrl('n')); editor.handle(Input::Escape); editor .apply(vec![Effect::Replace("different zz".into())]) .unwrap(); editor.handle(Input::Char('.')); assert_eq!(editor.context().text, "different final"); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "different zz"); editor.apply(vec![Effect::Replace("x".into())]).unwrap(); let revision = editor.text_revision(); let cursor = editor.cursor(); editor.handle(Input::Char('.')); assert_eq!(editor.context().text, "x"); assert_eq!(editor.cursor(), cursor); assert_eq!(editor.text_revision(), revision); assert!(editor.message().contains("outside")); editor .apply(vec![Effect::Replace("new yy".into())]) .unwrap(); editor.handle(Input::Char('.')); assert_eq!(editor.context().text, "new final"); } #[test] fn completion_additional_edits_translate_unicode_secondary_carets() { let mut editor = editor(); editor .apply(vec![Effect::Replace("e\u{301}x\nπŸ‘©β€πŸš€z".into())]) .unwrap(); editor.handle(Input::Ctrl('e')); let undo_carets = editor.carets(); editor.handle(Input::Char('A')); let original = editor.carets(); let mut item = completion_item((1, 0), (1, 2), "η•Œ\nε₯½"); item.edits.push(CompletionEdit { start: TextPosition { row: 0, column: 0 }, end: TextPosition { row: 0, column: 1 }, text: "prefix".into(), }); editor.show_completion(vec![item]).unwrap(); assert_eq!(editor.context().text, "prefixx\nη•Œ\nε₯½"); assert_eq!(editor.cursor(), Cursor { row: 2, column: 1 }); assert_eq!( editor.carets(), [Cursor { row: 0, column: 7 }, Cursor { row: 2, column: 1 },] ); editor.handle(Input::Ctrl('n')); assert_eq!(editor.context().text, "e\u{301}x\nπŸ‘©β€πŸš€z"); assert_eq!(editor.carets(), original); editor.handle(Input::Ctrl('k')); editor.handle(Input::Escape); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "e\u{301}x\nπŸ‘©β€πŸš€z"); assert_eq!(editor.carets(), undo_carets); } #[test] fn completion_validation_rejects_entire_set_before_any_mutation() { let mut editor = editor(); type_text(&mut editor, "iabc"); let good = completion_item((0, 0), (0, 3), "valid"); let mut bad_primary = good.clone(); bad_primary.primary_edit = 1; let mut overlap = good.clone(); overlap.edits.push(overlap.edits[0].clone()); for invalid in [ bad_primary, overlap, completion_item((1, 0), (1, 0), "bad"), completion_item((0, 4), (0, 4), "bad"), completion_item((0, 2), (0, 1), "bad"), completion_item((0, 0), (0, 3), "\0"), completion_item((0, 0), (0, 3), &"x".repeat(MAX_EDIT_BYTES + 1)), ] { let revision = editor.text_revision(); let steps = editor.active.pending_edit.as_ref().unwrap().steps.len(); assert!(editor.show_completion(vec![good.clone(), invalid]).is_err()); assert_eq!(editor.context().text, "abc"); assert_eq!(editor.cursor(), Cursor { row: 0, column: 3 }); assert_eq!(editor.text_revision(), revision); assert_eq!( editor.active.pending_edit.as_ref().unwrap().steps.len(), steps ); assert!(editor.completion().is_none()); assert!(editor.active.undo.is_empty()); } let revision = editor.text_revision(); editor.show_completion(Vec::new()).unwrap(); assert_eq!(editor.context().text, "abc"); assert_eq!(editor.text_revision(), revision); assert_eq!(editor.mode(), Mode::Insert); assert!(editor.completion().is_none()); assert_eq!(editor.message(), "No completions"); } #[test] fn completion_overlay_command_buffer_and_paste_boundaries_commit_displayed_choice() { for boundary in ["palette", "startup", "command", "paste", "rejected_paste"] { let mut editor = editor(); type_text(&mut editor, "iab"); editor .show_completion(vec![completion_item((0, 0), (0, 2), "chosen")]) .unwrap(); match boundary { "palette" => { editor.handle(Input::Ctrl('p')); } "startup" => editor.show_startup_diagnostic("notice".into()), "command" => { editor.execute("buffers"); } "paste" => { editor.handle(Input::Paste("!".into())); } _ => { editor.handle(Input::Paste("\0".into())); } } assert!(editor.completion().is_none()); assert_eq!( editor.context().text, if boundary == "paste" { "chosen!" } else { "chosen" } ); if boundary == "palette" || boundary == "startup" { editor.handle(Input::Escape); } if editor.mode() == Mode::Insert { editor.handle(Input::Escape); } if boundary == "paste" { editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "chosen"); } editor.handle(Input::Char('u')); assert_eq!(editor.context().text, ""); } let directory = Directory::new(); let other = directory.path("other"); fs::write(&other, "other").unwrap(); let mut editor = editor(); type_text(&mut editor, "iab"); editor .show_completion(vec![completion_item((0, 0), (0, 2), "chosen")]) .unwrap(); editor.execute(&format!("edit {}", other.display())); assert!(editor.completion().is_none()); editor.execute("buffer 1"); assert_eq!(editor.context().text, "chosen"); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, ""); } #[test] fn completion_dot_additional_edits_require_compatible_context_and_replay_atomically() { let mut editor = editor(); editor .apply(vec![Effect::Replace("use\nab".into())]) .unwrap(); type_text(&mut editor, "jA"); let mut item = completion_item((1, 0), (1, 2), "done"); item.edits.push(CompletionEdit { start: TextPosition { row: 0, column: 0 }, end: TextPosition { row: 0, column: 3 }, text: "import".into(), }); editor.show_completion(vec![item]).unwrap(); editor.handle(Input::Escape); editor .apply(vec![Effect::Replace("use\nzz".into())]) .unwrap(); type_text(&mut editor, "j."); assert_eq!(editor.context().text, "import\ndone"); editor .apply(vec![Effect::Replace("bad\nzz".into())]) .unwrap(); type_text(&mut editor, "j"); let before = editor.context().text; let carets = editor.carets(); let revision = editor.text_revision(); editor.handle(Input::Char('.')); assert_eq!(editor.context().text, before); assert_eq!(editor.carets(), carets); assert_eq!(editor.text_revision(), revision); assert!(editor.message().contains("context changed")); } fn snippet_item( start: (usize, usize), end: (usize, usize), text: &str, stops: Vec<(u32, Vec>)>, ) -> CompletionItem { let mut item = completion_item(start, end, text); item.snippet = Some(crate::plugin::CompletionSnippet { tabstops: stops .into_iter() .map(|(index, ranges)| SnippetTabstop { index, ancestors: vec![Vec::new(); ranges.len()], ranges, }) .collect(), }); item } fn nested_stop( mut item: CompletionItem, index: u32, ancestors: Vec>, ) -> CompletionItem { item.snippet .as_mut() .unwrap() .tabstops .iter_mut() .find(|stop| stop.index == index) .unwrap() .ancestors = ancestors; item } #[test] fn snippet_arguments_navigation_undo_redo_and_dot_use_resolved_text() { let directory = Directory::new(); let mut editor = editor(); type_text(&mut editor, "ica"); editor .show_completion(vec![snippet_item( (0, 0), (0, 2), "call(arg, count)", vec![(1, vec![5..8]), (2, vec![10..15]), (0, vec![16..16])], )]) .unwrap(); assert_eq!(editor.context().text, "call(arg, count)"); assert_eq!(editor.cursor(), Cursor { row: 0, column: 5 }); assert_eq!( editor.snippet_selections(), [Selection { start: Cursor { row: 0, column: 5 }, end: Cursor { row: 0, column: 8 }, }] ); type_text(&mut editor, "item"); assert!(editor.completion().is_none()); assert_eq!(editor.context().text, "call(item, count)"); editor.handle(Input::Char('\t')); type_text(&mut editor, "3"); editor.handle(Input::BackTab); type_text(&mut editor, "foo"); editor.handle(Input::Char('\t')); type_text(&mut editor, "5"); editor.handle(Input::Char('\t')); assert!(editor.snippet_selections().is_empty()); assert_eq!(editor.cursor(), Cursor { row: 0, column: 12 }); type_text(&mut editor, ";\n\ttail"); editor.handle(Input::Escape); let resolved = "call(foo, 5);\n\ttail"; let caret = editor.cursor(); assert_eq!(editor.context().text, resolved); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, ""); editor.handle(Input::Char('U')); assert_eq!(editor.context().text, resolved); assert_eq!(editor.cursor(), caret); editor.execute(&format!("edit {}", directory.path("repeat").display())); editor.handle(Input::Char('.')); assert_eq!(editor.context().text, resolved); assert_eq!(editor.cursor(), caret); assert!(editor.completion().is_none()); assert!(editor.snippet_selections().is_empty()); } #[test] fn snippet_empty_stops_unicode_multiline_and_mirrors_track_argument_edits() { let mut editor = editor(); editor.handle(Input::Char('i')); editor .show_completion(vec![snippet_item( (0, 0), (0, 0), "f(, πŸ˜€,\ne\u{301}, πŸ˜€)", vec![ (1, vec![2..2]), (2, vec![4..8, 15..19]), (3, vec![10..13]), (0, vec![20..20]), ], )]) .unwrap(); assert_eq!( editor.snippet_selections()[0].start, editor.snippet_selections()[0].end ); type_text(&mut editor, "δΈ­"); editor.handle(Input::Char('\t')); type_text(&mut editor, "πŸ¦€x"); editor.handle(Input::Backspace); assert_eq!(editor.context().text, "f(δΈ­, πŸ¦€,\ne\u{301}, πŸ¦€)"); assert_eq!(editor.snippet_selections().len(), 2); editor.handle(Input::Char('\t')); type_text(&mut editor, "a\u{301}"); assert_eq!(editor.context().text, "f(δΈ­, πŸ¦€,\na\u{301}, πŸ¦€)"); assert_eq!(editor.cursor(), Cursor { row: 1, column: 1 }); editor.handle(Input::BackTab); editor.handle(Input::Delete); assert_eq!(editor.context().text, "f(δΈ­, ,\na\u{301}, )"); editor.handle(Input::Escape); assert_eq!(editor.mode(), Mode::Normal); assert!(editor.snippet_selections().is_empty()); } #[test] fn snippet_nested_mirrors_update_parents_and_replacing_parent_retires_children() { let mut editor = editor(); editor.handle(Input::Char('i')); editor .show_completion(vec![nested_stop( snippet_item( (0, 0), (0, 0), "f(outer inner, outer inner, last)", vec![ (1, vec![2..13, 15..26]), (2, vec![8..13, 21..26]), (3, vec![28..32]), (0, vec![33..33]), ], ), 2, vec![vec![1]; 2], )]) .unwrap(); editor.handle(Input::Char('\t')); type_text(&mut editor, "word"); assert_eq!(editor.context().text, "f(outer word, outer word, last)"); editor.handle(Input::BackTab); type_text(&mut editor, "body"); editor.handle(Input::Char('\t')); type_text(&mut editor, "end"); editor.handle(Input::Char('\t')); assert_eq!(editor.context().text, "f(body, body, end)"); assert!(editor.snippet_selections().is_empty()); let mut editor = self::editor(); editor.handle(Input::Char('i')); editor .show_completion(vec![nested_stop( snippet_item( (0, 0), (0, 0), "f(foo, last)", vec![ (1, vec![2..5]), (2, vec![2..5]), (3, vec![7..11]), (0, vec![12..12]), ], ), 2, vec![vec![1]], )]) .unwrap(); type_text(&mut editor, "value"); editor.handle(Input::Char('\t')); type_text(&mut editor, "end"); assert_eq!(editor.context().text, "f(value, end)"); } #[test] fn snippet_cycling_restores_carets_and_argument_edits_never_broadcast_to_secondaries() { let mut editor = editor(); editor .apply(vec![Effect::Replace("ab\nzz".into())]) .unwrap(); editor.handle(Input::Ctrl('e')); let undo_carets = editor.carets(); editor.handle(Input::Char('A')); let original = editor.carets(); let mut item = snippet_item( (1, 0), (1, 2), "f(arg)", vec![(1, vec![2..5]), (0, vec![6..6])], ); item.edits.push(CompletionEdit { start: TextPosition { row: 0, column: 0 }, end: TextPosition { row: 0, column: 1 }, text: "prefix".into(), }); editor.show_completion(vec![item]).unwrap(); assert_eq!( editor.carets(), [Cursor { row: 0, column: 7 }, Cursor { row: 1, column: 2 },] ); editor.handle(Input::Ctrl('n')); assert_eq!(editor.context().text, "ab\nzz"); assert_eq!(editor.carets(), original); assert!(editor.snippet_selections().is_empty()); editor.handle(Input::Ctrl('k')); type_text(&mut editor, "long"); assert_eq!(editor.context().text, "prefixb\nf(long)"); assert_eq!(editor.carets()[0], Cursor { row: 0, column: 7 }); editor.handle(Input::Escape); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "ab\nzz"); assert_eq!(editor.carets(), undo_carets); } #[test] fn snippet_keys_respect_disabled_remapped_bindings_and_tab_remains_literal_outside() { let mut editor = editor(); type_text(&mut editor, "i\t"); assert_eq!(editor.context().text, "\t"); editor.bind("insert", "Tab", None).unwrap(); editor .show_completion(vec![snippet_item( (0, 0), (0, 1), "f(one, two)", vec![(1, vec![2..5]), (2, vec![7..10]), (0, vec![11..11])], )]) .unwrap(); editor.handle(Input::Char('\t')); assert_eq!(editor.cursor(), Cursor { row: 0, column: 2 }); editor .bind("insert", "Ctrl+f", Some("core.snippet.next".into())) .unwrap(); editor .bind("insert", "Shift+Tab", Some("core.snippet.previous".into())) .unwrap(); editor.handle(Input::Ctrl('f')); assert_eq!(editor.cursor(), Cursor { row: 0, column: 7 }); editor.handle(Input::BackTab); assert_eq!(editor.cursor(), Cursor { row: 0, column: 2 }); editor .bind("insert", "Tab", Some("core.right".into())) .unwrap(); editor.handle(Input::Char('\t')); assert!(editor.snippet_selections().is_empty()); assert_eq!(editor.cursor(), Cursor { row: 0, column: 3 }); type_text(&mut editor, "x"); assert_eq!(editor.context().text, "f(oxne, two)"); } #[test] fn snippet_validation_is_atomic_and_newline_offsets_are_normalized() { let mut editor = editor(); type_text(&mut editor, "iab"); for stops in [ vec![(1, vec![2..3]), (0, vec![8..8])], vec![(2, vec![1..5]), (1, vec![5..7]), (0, vec![8..8])], vec![(1, vec![1..6]), (2, vec![5..7]), (0, vec![8..8])], vec![(1, vec![1..5, 5..7]), (0, vec![8..8])], vec![(1, vec![1..5])], ] { let before = editor.context().text; let carets = editor.carets(); let item = snippet_item((0, 0), (0, 2), "(πŸ˜€ab)", stops); assert!(editor.show_completion(vec![item]).is_err()); assert_eq!(editor.context().text, before); assert_eq!(editor.carets(), carets); assert!(editor.completion().is_none()); } let before = editor.context().text; let carets = editor.carets(); assert!( editor .show_completion(vec![snippet_item( (0, 0), (0, 2), "f(\r\narg)", vec![(1, vec![3..6]), (0, vec![7..7])], )]) .is_err() ); assert_eq!(editor.context().text, before); assert_eq!(editor.carets(), carets); editor .show_completion(vec![snippet_item( (0, 0), (0, 2), "f(\narg)", vec![(1, vec![3..6]), (0, vec![7..7])], )]) .unwrap(); assert_eq!(editor.context().text, "f(\narg)"); assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 }); type_text(&mut editor, "η•Œ"); assert_eq!(editor.context().text, "f(\nη•Œ)"); } #[test] fn snippet_motion_overlay_command_plugin_and_paste_boundaries_end_navigation() { for boundary in [ "motion", "overlay", "command", "plugin", "paste", "invalid_paste", ] { let mut editor = editor(); editor.handle(Input::Char('i')); editor .show_completion(vec![snippet_item( (0, 0), (0, 0), "f(arg)", vec![(1, vec![2..5]), (0, vec![6..6])], )]) .unwrap(); match boundary { "motion" => { editor.handle(Input::Right); } "overlay" => { editor.handle(Input::Ctrl('p')); } "command" => { editor.execute("buffers"); } "plugin" => { editor .apply(vec![Effect::Replace("replacement".into())]) .unwrap(); } "paste" => { editor.handle(Input::Paste("literal".into())); } "invalid_paste" => { editor.handle(Input::Paste("\0".into())); } _ => unreachable!(), } assert!(editor.snippet_selections().is_empty(), "{boundary}"); assert!(editor.completion().is_none(), "{boundary}"); } } #[test] fn snippet_final_caret_in_middle_replays_before_later_ordinary_typing() { let directory = Directory::new(); let mut editor = editor(); editor.handle(Input::Char('i')); editor .show_completion(vec![snippet_item( (0, 0), (0, 0), "f(arg) tail", vec![(1, vec![2..5]), (0, vec![6..6])], )]) .unwrap(); type_text(&mut editor, "X"); editor.handle(Input::Char('\t')); assert_eq!(editor.cursor(), Cursor { row: 0, column: 4 }); type_text(&mut editor, "!"); editor.handle(Input::Escape); let caret = editor.cursor(); assert_eq!(editor.context().text, "f(X)! tail"); editor.execute(&format!("edit {}", directory.path("repeat").display())); editor.handle(Input::Char('.')); assert_eq!(editor.context().text, "f(X)! tail"); assert_eq!(editor.cursor(), caret); } #[test] fn snippet_adjacent_empty_stops_remain_independent_when_typed() { let mut editor = editor(); editor.handle(Input::Char('i')); editor .show_completion(vec![snippet_item( (0, 0), (0, 0), "f()", vec![(1, vec![2..2]), (2, vec![2..2]), (0, vec![3..3])], )]) .unwrap(); type_text(&mut editor, "one"); editor.handle(Input::Char('\t')); type_text(&mut editor, "two"); editor.handle(Input::BackTab); type_text(&mut editor, "first"); editor.handle(Input::Char('\t')); type_text(&mut editor, "second"); assert_eq!(editor.context().text, "f(firstsecond)"); } #[test] fn snippet_ancestry_distinguishes_nested_empty_boundary_from_adjacent_root() { for nested in [false, true] { let mut editor = editor(); editor.handle(Input::Char('i')); let item = snippet_item( (0, 0), (0, 0), "f(foo)", vec![(1, vec![2..5]), (2, vec![5..5]), (0, vec![6..6])], ); let item = if nested { nested_stop(item, 2, vec![vec![1]]) } else { item }; editor.show_completion(vec![item]).unwrap(); type_text(&mut editor, "X"); editor.handle(Input::Char('\t')); if nested { assert!(editor.snippet_selections().is_empty()); assert_eq!(editor.cursor(), Cursor { row: 0, column: 4 }); } else { assert_eq!(editor.cursor(), Cursor { row: 0, column: 3 }); assert_eq!(editor.snippet_selections().len(), 1); type_text(&mut editor, "Y"); editor.handle(Input::BackTab); type_text(&mut editor, "Z"); assert_eq!(editor.context().text, "f(ZY)"); } } let mut editor = editor(); editor.handle(Input::Char('i')); editor .show_completion(vec![nested_stop( snippet_item( (0, 0), (0, 0), "f(foo)", vec![(1, vec![2..5]), (2, vec![5..5]), (0, vec![6..6])], ), 2, vec![vec![1]], )]) .unwrap(); editor.handle(Input::Char('\t')); type_text(&mut editor, "X"); editor.handle(Input::BackTab); type_text(&mut editor, "Y"); editor.handle(Input::Char('\t')); assert_eq!(editor.context().text, "f(Y)"); assert!(editor.snippet_selections().is_empty()); } #[test] fn snippet_replacing_parent_preserves_unrelated_child_mirror_occurrences() { let mut editor = editor(); editor.handle(Input::Char('i')); editor .show_completion(vec![nested_stop( snippet_item( (0, 0), (0, 0), "f(foo, foo)", vec![(1, vec![2..5]), (2, vec![2..5, 7..10]), (0, vec![11..11])], ), 2, vec![vec![1], vec![]], )]) .unwrap(); type_text(&mut editor, "X"); editor.handle(Input::Char('\t')); assert_eq!(editor.cursor(), Cursor { row: 0, column: 5 }); type_text(&mut editor, "Y"); assert_eq!(editor.context().text, "f(X, Y)"); } #[test] fn snippet_coincident_empty_mirror_multiplicity_survives_typing_and_deletion() { let mut editor = editor(); editor.handle(Input::Char('i')); editor .show_completion(vec![snippet_item( (0, 0), (0, 0), "f()", vec![(1, vec![2..2, 2..2]), (0, vec![3..3])], )]) .unwrap(); type_text(&mut editor, "xy"); assert_eq!(editor.context().text, "f(xyxy)"); editor.handle(Input::Backspace); assert_eq!(editor.context().text, "f(xx)"); editor.handle(Input::Backspace); assert_eq!(editor.context().text, "f()"); type_text(&mut editor, "z"); assert_eq!(editor.context().text, "f(zz)"); editor.handle(Input::Char('\t')); assert_eq!(editor.cursor(), Cursor { row: 0, column: 5 }); assert!(editor.snippet_selections().is_empty()); editor.handle(Input::Char('\t')); assert_eq!(editor.context().text, "f(zz)\t"); } #[test] fn snippet_ancestry_validation_rejects_misalignment_unknown_ids_cycles_and_false_containment() { let mut editor = editor(); editor.handle(Input::Char('i')); let item = || { snippet_item( (0, 0), (0, 0), "f(foo, bar)", vec![(1, vec![2..5]), (2, vec![7..10]), (0, vec![11..11])], ) }; let mut misaligned = item(); misaligned.snippet.as_mut().unwrap().tabstops[0] .ancestors .clear(); let unknown = nested_stop(item(), 2, vec![vec![999]]); let containment = nested_stop(item(), 2, vec![vec![1]]); let cycle = nested_stop(nested_stop(item(), 1, vec![vec![2]]), 2, vec![vec![1]]); for candidate in [misaligned, unknown, containment, cycle] { assert!(editor.show_completion(vec![candidate]).is_err()); assert_eq!(editor.context().text, ""); assert_eq!(editor.cursor(), Cursor::default()); assert!(editor.completion().is_none()); } } }