use serde::Serialize; use std::borrow::Cow; use std::collections::{HashMap, HashSet}; use std::fmt; use std::fs::{self, OpenOptions}; use std::io::{self, Write}; #[cfg(unix)] use std::os::unix::fs::OpenOptionsExt; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; use unicode_segmentation::{GraphemeCursor, UnicodeSegmentation}; use unicode_width::UnicodeWidthStr; const MAX_EDIT_BYTES: usize = 4 * 1024 * 1024; static TEMP_ID: AtomicU64 = AtomicU64::new(0); #[derive(Clone, Debug, Eq, Hash, PartialEq)] pub enum Input { Char(char), Ctrl(char), Paste(String), Escape, Enter, Backspace, Delete, Left, Right, Up, Down, Home, End, } #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] pub enum Mode { Normal, Insert, Command, Search, Visual, } impl fmt::Display for Mode { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(match self { Self::Normal => "NORMAL", Self::Insert => "INSERT", Self::Command => "COMMAND", Self::Search => "SEARCH", Self::Visual => "VISUAL", }) } } #[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 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, } } } struct Snapshot { lines: Vec, targets: Vec, primary: usize, } struct Edit<'a> { target: usize, start: usize, end: usize, text: Cow<'a, str>, caret: usize, round_up: bool, } #[derive(Clone, Copy)] enum Action { Insert, InsertFirstNonblank, Append, AppendLineEnd, OpenBelow, OpenAbove, Left, Right, Down, Up, JoinLines, LineStart, LineEnd, Delete, Undo, Redo, Command, Normal, Newline, Backspace, Submit, Search, SearchNext, SearchPrevious, WordNext, WordPrevious, WordEnd, BigWordNext, BigWordPrevious, BigWordEnd, Select, Copy, Cut, Change, PasteBefore, PasteAfter, SelectLines, CloneAbove, CloneBelow, SelectNext, SkipOccurrence, SelectAll, } impl Action { fn named(mode: Mode, name: &str) -> Option { use Action::*; Some(match (mode, name) { (Mode::Normal, "core.insert") => Insert, (Mode::Normal | Mode::Visual, "core.insert_first_nonblank") => InsertFirstNonblank, (Mode::Normal, "core.append") => Append, (Mode::Normal | Mode::Visual, "core.append_line_end") => AppendLineEnd, (Mode::Normal, "core.open_below") => OpenBelow, (Mode::Normal, "core.open_above") => OpenAbove, (Mode::Normal, "core.join_lines") => JoinLines, (Mode::Normal, "core.undo") => Undo, (Mode::Normal, "core.redo") => Redo, (Mode::Normal, "core.command") => Command, (Mode::Normal, "core.search") => Search, (Mode::Normal | Mode::Visual, "core.search_next") => SearchNext, (Mode::Normal | Mode::Visual, "core.search_previous") => SearchPrevious, (Mode::Normal | Mode::Visual, "core.select") => Select, (Mode::Visual, "core.select_lines") => SelectLines, (Mode::Normal | Mode::Visual, "core.clone_above") => CloneAbove, (Mode::Normal | Mode::Visual, "core.clone_below") => CloneBelow, (Mode::Visual, "core.select_next") => SelectNext, (Mode::Visual, "core.skip_occurrence") => SkipOccurrence, (Mode::Visual, "core.select_all") => SelectAll, (Mode::Visual, "core.copy") => Copy, (Mode::Visual, "core.cut") => Cut, (Mode::Visual, "core.change") => Change, (Mode::Normal, "core.paste_before") => PasteBefore, (Mode::Normal, "core.paste_after") => PasteAfter, (Mode::Normal | Mode::Insert | Mode::Visual, "core.left") => Left, (Mode::Normal | Mode::Insert | Mode::Visual, "core.right") => Right, (Mode::Normal | Mode::Insert | Mode::Visual, "core.down") => Down, (Mode::Normal | Mode::Insert | Mode::Visual, "core.up") => Up, (Mode::Normal | Mode::Insert | Mode::Visual, "core.line_start") => LineStart, (Mode::Normal | Mode::Insert | Mode::Visual, "core.line_end") => LineEnd, (Mode::Normal | Mode::Insert | Mode::Visual, "core.word_next") => WordNext, (Mode::Normal | Mode::Insert | Mode::Visual, "core.word_previous") => WordPrevious, (Mode::Normal | Mode::Insert | Mode::Visual, "core.word_end") => WordEnd, (Mode::Normal | Mode::Insert | Mode::Visual, "core.big_word_next") => BigWordNext, (Mode::Normal | Mode::Insert | Mode::Visual, "core.big_word_previous") => { BigWordPrevious } (Mode::Normal | Mode::Insert | Mode::Visual, "core.big_word_end") => BigWordEnd, (Mode::Normal | Mode::Insert, "core.delete") => Delete, (_, "core.normal") => Normal, (Mode::Insert, "core.newline") => Newline, (Mode::Insert | Mode::Command | Mode::Search, "core.backspace") => Backspace, (Mode::Command | Mode::Search, "core.submit") => Submit, _ => return None, }) } } enum Binding { Action(Action), Command(String), Disabled, } fn parse_key(key: &str) -> Result { let character = |name: &str| { let mut chars = name.chars(); chars .next() .filter(|ch| !ch.is_control() && chars.next().is_none()) }; // Keep literal punctuation, especially "+", usable without escaping. if let Some(ch) = character(key) { return Ok(Input::Char(ch)); } let invalid = || format!("Invalid binding key: {key:?}"); let (modifier, name) = key .split_once('+') .map_or((None, key), |(modifier, name)| (Some(modifier), name)); let input = if let Some(ch) = character(name) { Input::Char(ch) } else { [ ("esc", Input::Escape), ("enter", Input::Enter), ("backspace", Input::Backspace), ("delete", Input::Delete), ("left", Input::Left), ("right", Input::Right), ("up", Input::Up), ("down", Input::Down), ("home", Input::Home), ("end", Input::End), ("tab", Input::Char('\t')), ("space", Input::Char(' ')), ("minus", Input::Char('-')), ("comma", Input::Char(',')), ("ampersand", Input::Char('&')), ("plus", Input::Char('+')), ("backtick", Input::Char('`')), ] .into_iter() .find(|(key, _)| name.eq_ignore_ascii_case(key)) .map(|(_, input)| input) .ok_or_else(invalid)? }; match (modifier, input) { (None, input) => Ok(input), (Some(modifier), Input::Char(ch)) if ch.is_ascii_alphabetic() => { if modifier.eq_ignore_ascii_case("ctrl") { Ok(control_key(ch)) } else if modifier.eq_ignore_ascii_case("shift") { Ok(Input::Char(ch.to_ascii_uppercase())) } else { Err(invalid()) } } _ => Err(invalid()), } } fn control_key(ch: char) -> Input { // Terminals encode these controls as named keys. match ch.to_ascii_lowercase() { 'h' => Input::Backspace, 'i' => Input::Char('\t'), 'j' | 'm' => Input::Enter, ch => Input::Ctrl(ch), } } fn default_bindings() -> HashMap> { use Action::*; let mut bindings: HashMap> = HashMap::new(); for (key, action) in [ ('i', Insert), ('I', InsertFirstNonblank), ('a', Append), ('A', AppendLineEnd), ('o', OpenBelow), ('O', OpenAbove), ('J', JoinLines), ('x', Delete), ('u', Undo), ('U', Redo), (':', Command), ('/', Search), ('p', PasteAfter), ('P', PasteBefore), ] { bindings .entry(Mode::Normal) .or_default() .insert(Input::Char(key), Binding::Action(action)); } for mode in [Mode::Normal, Mode::Visual] { for (key, action) in [ ('h', Left), ('j', Down), ('k', Up), ('l', Right), ('0', LineStart), ('$', LineEnd), ('w', WordNext), ('b', WordPrevious), ('e', WordEnd), ('W', BigWordNext), ('B', BigWordPrevious), ('E', BigWordEnd), ('n', SearchNext), ('N', SearchPrevious), ('v', Select), ] { bindings .entry(mode) .or_default() .insert(Input::Char(key), Binding::Action(action)); } for (key, action) in [ (Input::Ctrl('k'), CloneAbove), (Input::Ctrl('e'), CloneBelow), (Input::Escape, Normal), ] { bindings .entry(mode) .or_default() .insert(key, Binding::Action(action)); } } for mode in [Mode::Normal, Mode::Insert, Mode::Visual] { for (key, action) in [ (Input::Left, Left), (Input::Right, Right), (Input::Down, Down), (Input::Up, Up), (Input::Home, LineStart), (Input::End, LineEnd), ] { bindings .entry(mode) .or_default() .insert(key, Binding::Action(action)); } } for mode in [Mode::Normal, Mode::Insert] { bindings .entry(mode) .or_default() .insert(Input::Delete, Binding::Action(Delete)); } bindings .entry(Mode::Visual) .or_default() .insert(Input::Escape, Binding::Action(Normal)); for (key, action) in [ (Input::Char('y'), Copy), (Input::Char('d'), Cut), (Input::Char('c'), Change), (Input::Char('x'), Cut), (Input::Delete, Cut), (Input::Char('I'), InsertFirstNonblank), (Input::Char('A'), AppendLineEnd), (Input::Ctrl('l'), SelectLines), (Input::Ctrl('n'), SelectNext), (Input::Ctrl('s'), SkipOccurrence), (Input::Ctrl('a'), SelectAll), ] { bindings .entry(Mode::Visual) .or_default() .insert(key, Binding::Action(action)); } for mode in [Mode::Insert, Mode::Command, Mode::Search] { for (key, action) in [ (Input::Escape, Normal), (Input::Backspace, Backspace), ( Input::Enter, if mode == Mode::Insert { Newline } else { Submit }, ), ] { bindings .entry(mode) .or_default() .insert(key, Binding::Action(action)); } } bindings } pub struct Editor { lines: Vec, saved_lines: Vec, targets: Vec, primary: usize, mode: Mode, command_line: String, search_query: String, search_match: Option, clipboard: Vec, message: String, path: Option, quit: bool, capabilities: Vec, features: HashMap, bindings: HashMap>, // ponytail: full-buffer history costs O(buffer size) per action; use edit deltas for large files. undo: Vec, redo: Vec, insert_start: Option, } impl Editor { pub fn open(path: Option, capabilities: Vec) -> io::Result { let text = match &path { Some(path) => match fs::read_to_string(path) { Ok(text) => text, Err(error) if 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); let mut editor = Self { saved_lines: lines.clone(), lines, targets: vec![Target::caret(Cursor::default())], primary: 0, mode: Mode::Normal, command_line: String::new(), search_query: String::new(), search_match: None, clipboard: Vec::new(), message: String::new(), path, quit: false, capabilities, features: HashMap::new(), bindings: default_bindings(), undo: Vec::new(), redo: Vec::new(), insert_start: None, }; editor .install( Feature { name: "core".into(), required_capabilities: Vec::new(), commands: ["w", "q", "q!", "wq", "plugins"] .into_iter() .map(str::to_owned) .collect(), core: true, }, true, ) .map_err(io::Error::other)?; Ok(editor) } pub fn lines(&self) -> &[String] { &self.lines } pub fn cursor(&self) -> Cursor { self.targets[self.primary].active } pub fn mode(&self) -> Mode { self.mode } /// Editable Ex/search prompt, without its `:` or `/` prefix. pub fn command_line(&self) -> &str { &self.command_line } pub fn search_match(&self) -> Option { self.search_match } /// Ordered, end-exclusive Visual ranges in document order; empty in other modes. pub fn selections(&self) -> Vec { if self.mode == Mode::Visual { self.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.targets.iter().map(|target| target.active).collect() } pub fn selection_count(&self) -> usize { self.targets.len() } pub fn message(&self) -> &str { &self.message } pub fn path(&self) -> Option<&Path> { self.path.as_deref() } pub fn dirty(&self) -> bool { self.lines != self.saved_lines } pub fn should_quit(&self) -> bool { self.quit } pub fn context(&self) -> Context { Context { text: self.lines.join("\n"), row: self.cursor().row + 1, column: self.cursor().column + 1, mode: self.mode.to_string(), path: self .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); 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) { if self.features.get(name).is_some_and(|feature| feature.core) { self.set_message("Core features cannot be disabled"); return; } 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)) }); } } self.set_message(format!("Plugin {name} disabled: {reason}")); } /// Overrides one mode's binding. `None` disables the key, including literal /// typing in Insert/Command mode. Invalid overrides leave the binding intact. pub fn bind(&mut self, mode: &str, key: &str, target: Option) -> Result<(), String> { 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 key = parse_key(key)?; 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.bindings.entry(mode).or_default().insert(key, binding); Ok(()) } pub fn handle(&mut self, input: Input) -> Option { // Paste is literal input, never a configurable key or command. if let Input::Paste(text) = input { if self.mode == Mode::Insert { self.paste(&text); } return None; } // Normalize terminal aliases for every frontend. let input = match input { Input::Ctrl(ch) => control_key(ch), input => input, }; match self .bindings .get(&self.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) => return None, None => {} } match (self.mode, input) { (Mode::Insert, Input::Char(ch)) if !ch.is_control() || ch == '\t' => { let mut bytes = [0; 4]; self.insert_text(ch.encode_utf8(&mut bytes)); } (Mode::Command | Mode::Search, Input::Char(ch)) if !ch.is_control() || (self.mode == Mode::Search && ch == '\t') => { self.command_line.push(ch); } _ => {} } None } fn act(&mut self, action: Action) -> Option { match action { Action::Insert => self.begin_insert(), Action::InsertFirstNonblank | Action::AppendLineEnd => { if self.mode == Mode::Visual { let before = self.snapshot(); self.split_rows(matches!(action, Action::AppendLineEnd), true); self.begin_insert(); self.insert_start = Some(before); } else { self.begin_insert(); for target in &mut self.targets { let line = &self.lines[target.active.row]; target.active.column = if matches!(action, Action::AppendLineEnd) { grapheme_count(line) } else { first_nonblank(line) }; } self.normalize_targets(); } } Action::Append => { self.begin_insert(); for target in &mut self.targets { target.active.column = (target.active.column + 1) .min(grapheme_count(&self.lines[target.active.row])); } self.normalize_targets(); } Action::OpenBelow | Action::OpenAbove => { self.begin_insert(); self.open_lines(matches!(action, Action::OpenBelow)); } Action::Left | Action::Right | Action::Down | Action::Up | Action::LineStart | Action::LineEnd | Action::WordNext | Action::WordPrevious | Action::WordEnd | Action::BigWordNext | Action::BigWordPrevious | Action::BigWordEnd => self.move_targets(action), Action::JoinLines => self.join_lines(), Action::Delete => { let before = (self.mode != Mode::Insert).then(|| self.snapshot()); self.delete_forward(); if let Some(before) = before { self.record(before); } } Action::Undo => self.undo(), Action::Redo => self.redo(), Action::Command | Action::Search => { self.mode = if matches!(action, Action::Search) { Mode::Search } else { Mode::Command }; self.command_line.clear(); } Action::SearchNext => self.seek_search(true), Action::SearchPrevious => self.seek_search(false), Action::Select => { if self.mode == Mode::Visual { self.clear_selection(); } else { for target in &mut self.targets { target.anchor = target.active; } self.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::Cut | Action::Change => self.edit_selection(action), Action::PasteBefore => self.paste_clipboard(false), Action::PasteAfter => self.paste_clipboard(true), Action::Normal => { if self.mode == Mode::Normal { self.collapse_primary(); } else if self.mode == Mode::Insert { self.finish_insert(); self.mode = Mode::Normal; for target in &mut self.targets { target.active.column = target.active.column.saturating_sub(1); } self.normalize_targets(); } else { self.command_line.clear(); self.mode = Mode::Normal; self.normalize_targets(); } } Action::Newline => self.insert_text("\n"), Action::Backspace => { if self.mode == Mode::Insert { self.backspace(); } 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.mode == Mode::Search; self.mode = Mode::Normal; if searching { if !command.is_empty() { self.search_query = command; } self.seek_search(true); } else { return self.execute(&command); } } } None } fn seek_search(&mut self, forward: bool) { self.search_match = None; if self.search_query.is_empty() { self.set_message("No previous search"); return; } let query = self.search_query.as_str(); // ponytail: O(buffer graphemes ร— query bytes) scan; index matches if large buffers need it. let matches: Vec<_> = self .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.search_query)); return; } for (index, target) in self.targets.iter_mut().enumerate() { let origin = ( target.active.row, grapheme_byte(&self.lines[target.active.row], target.active.column), ); let found = if forward { select_search_match(matches.iter().copied(), origin, true) } else { select_search_match(matches.iter().copied().rev(), origin, false) } .expect("nonempty matches"); target.active = Cursor { row: found.0, column: column_after_byte(&self.lines[found.0], found.1), }; target.preferred_columns = None; if index == self.primary { self.search_match = Some(SearchMatch { start: target.active, end_column: target.active.column + grapheme_count(query), }); } } self.normalize_targets(); self.message.clear(); } pub fn execute(&mut self, command: &str) -> Option { if self.mode == Mode::Insert { self.finish_insert(); } self.clear_selection(); self.mode = Mode::Normal; self.normalize_targets(); 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(); let Some(feature) = self.owner(name) else { self.set_message(format!("Unknown command: {name}")); return None; }; if !feature.core { return Some(Invocation { plugin: feature.name.clone(), command: name.into(), args: args.into(), }); } match name { "w" | "wq" => { if self.save(if args.is_empty() { None } else { Some(PathBuf::from(args)) }) && name == "wq" { self.quit = true; } } "q" | "q!" if !args.is_empty() => self.set_message("Quit commands take no arguments"), "q" if self.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 inserting = self.mode == Mode::Insert; if inserting { self.finish_insert(); } let before = effects .iter() .any(|effect| { matches!(effect, Effect::Insert(text) if !text.is_empty()) || matches!(effect, Effect::Replace(_)) }) .then(|| self.snapshot()); if before.is_some() { self.clear_selection(); self.collapse_primary(); // Plugin inserts use one primary insertion point, not the selection set. self.mode = Mode::Insert; } for effect in effects { match effect { Effect::Insert(text) => self.insert_text(&normalize_newlines(&text)), Effect::Replace(text) => { self.lines = split_lines(&text); self.search_match = None; self.normalize_targets(); } Effect::Message(text) => self.set_message(text), } } if before.is_some() { self.mode = if inserting { Mode::Insert } else { Mode::Normal }; } self.normalize_targets(); if let Some(before) = before { self.record(before); } if inserting { self.insert_start = Some(self.snapshot()); } Ok(()) } fn save(&mut self, path: Option) -> bool { let Some(path) = path.or_else(|| self.path.clone()) else { self.set_message("No file name; use :w path"); return false; }; match atomic_save(&path, &self.lines) { Ok(()) => { self.saved_lines.clone_from(&self.lines); self.path = Some(path); self.set_message("Written"); true } Err(error) => { self.set_message(format!("Write failed: {error}")); false } } } fn snapshot(&self) -> Snapshot { Snapshot { lines: self.lines.clone(), targets: self.targets.clone(), primary: self.primary, } } fn record(&mut self, before: Snapshot) { if before.lines != self.lines { self.search_match = None; self.undo.push(before); self.redo.clear(); } } fn target_range(&self, target: Target) -> Selection { let start = target.anchor.min(target.active); let end = self.next_boundary(target.anchor.max(target.active)); Selection { start, end } } fn next_boundary(&self, mut cursor: Cursor) -> Cursor { if cursor.column < grapheme_count(&self.lines[cursor.row]) { cursor.column += 1; } else if cursor.row + 1 < self.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.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.lines[row]); if self.mode == Mode::Insert || (self.mode == Mode::Visual && row + 1 < self.lines.len()) { count } else { count.saturating_sub(1) } } fn normalize_targets(&mut self) { for index in 0..self.targets.len() { let mut target = self.targets[index]; target.active.row = target.active.row.min(self.lines.len() - 1); target.active.column = target.active.column.min(self.max_column(target.active.row)); if self.mode == Mode::Visual { target.anchor.row = target.anchor.row.min(self.lines.len() - 1); target.anchor.column = target.anchor.column.min(self.max_column(target.anchor.row)); } else { target.anchor = target.active; } self.targets[index] = target; } let mut ordered: Vec<_> = self .targets .iter() .copied() .enumerate() .map(|(index, target)| { let range = if self.mode == Mode::Visual { self.target_range(target) } else { Selection { start: target.active, end: target.active, } }; (target, index == self.primary, range) }) .collect(); 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() { if 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.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)); } self.primary = merged .iter() .position(|(_, primary, _)| *primary) .unwrap_or(0); self.targets.clear(); self.targets .extend(merged.into_iter().map(|(target, _, _)| target)); } fn collapse_primary(&mut self) { let target = self.targets[self.primary]; self.targets.clear(); self.targets.push(target); self.primary = 0; } fn clear_selection(&mut self) { if self.mode == Mode::Visual { self.mode = Mode::Normal; self.normalize_targets(); } } fn split_rows(&mut self, end: bool, insert: bool) { let ranges = self.selections(); let primary_row = self.cursor().row; let mut carets = Vec::new(); let mut primary = 0; for (index, range) in ranges.into_iter().enumerate() { let last = if range.end.column == 0 && range.end.row > range.start.row { range.end.row - 1 } else { range.end.row }; for row in range.start.row..=last { if index == self.primary && row == primary_row { primary = carets.len(); } let column = if end { grapheme_count(&self.lines[row]) } else { first_nonblank(&self.lines[row]) }; carets.push(Target::caret(Cursor { row, column })); } } self.targets = carets; self.primary = primary; self.mode = if insert { Mode::Insert } else { Mode::Normal }; self.normalize_targets(); } fn clone_vertical(&mut self, down: bool) { let count = self.targets.len(); let primary = self.primary; for index in 0..count { let target = self.targets[index]; let shift = |row: usize| { if down { row.checked_add(1).filter(|row| *row < self.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.lines[target.anchor.row], target.anchor.column), display_column(&self.lines[target.active.row], target.active.column), ) }); self.targets[index].preferred_columns = Some(columns); let cloned = Target { anchor: Cursor { row: anchor_row, column: column_at_display( &self.lines[anchor_row], columns.0, self.max_column(anchor_row), ), }, active: Cursor { row: active_row, column: column_at_display( &self.lines[active_row], columns.1, self.max_column(active_row), ), }, preferred_columns: Some(columns), }; if index == primary { self.primary = self.targets.len(); } self.targets.push(cloned); } self.normalize_targets(); } fn select_occurrences(&mut self, action: Action) { let text = self.lines.join("\n"); let offsets = line_offsets(&self.lines); let ranges = self.selections(); let primary_range = ranges[self.primary]; let start = cursor_offset(&self.lines, &offsets, primary_range.start); let end = cursor_offset(&self.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.lines, &offsets, range.start), cursor_offset(&self.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.targets.push(self.range_target( Selection { start: offset_cursor(&self.lines, &offsets, start, true), end: offset_cursor(&self.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.lines, &offsets, start, true), end: offset_cursor(&self.lines, &offsets, end, true), }, true, ); if matches!(action, Action::SkipOccurrence) { self.targets[self.primary] = target; } else { self.primary = self.targets.len(); self.targets.push(target); } } self.normalize_targets(); self.message.clear(); } fn edit_selection(&mut self, action: Action) { let ranges = self.selections(); let cut = matches!(action, Action::Cut | Action::Change); let changing = matches!(action, Action::Change); let before = cut.then(|| self.snapshot()); let text = self.lines.join("\n"); let offsets = line_offsets(&self.lines); let fragments: Vec<_> = ranges .iter() .map(|range| { let start = cursor_offset(&self.lines, &offsets, range.start); let end = cursor_offset(&self.lines, &offsets, range.end); text[start..end].to_owned() }) .collect(); let nonempty = fragments.iter().any(|fragment| !fragment.is_empty()); if nonempty { self.clipboard = fragments; } let edits = ranges .iter() .enumerate() .map(|(target, range)| Edit { target, start: cursor_offset(&self.lines, &offsets, range.start), end: cursor_offset(&self.lines, &offsets, range.end), text: Cow::Borrowed(""), caret: 0, round_up: true, }) .collect(); self.mode = Mode::Insert; if cut { self.apply_edits(edits); } else { for (target, range) in self.targets.iter_mut().zip(&ranges) { target.active = range.start; } } if changing { self.insert_start = before; self.message.clear(); } else { self.mode = Mode::Normal; self.normalize_targets(); if let Some(before) = before { self.record(before); } self.set_message(if !nonempty { "Nothing selected" } else if cut { "Cut selections" } else { "Copied selections" }); } } fn paste_clipboard(&mut self, after: bool) { if self.clipboard.is_empty() { self.set_message("Clipboard is empty"); return; } if self.clipboard.len() != 1 && self.clipboard.len() != self.targets.len() { self.set_message("Clipboard fragment count does not match caret count"); return; } let before = self.snapshot(); let clipboard = std::mem::take(&mut self.clipboard); let offsets = line_offsets(&self.lines); let edits = self .targets .iter() .enumerate() .map(|(target, item)| { let mut cursor = item.active; if after { cursor.column = (cursor.column + 1).min(grapheme_count(&self.lines[cursor.row])); } let start = cursor_offset(&self.lines, &offsets, cursor); let text = &clipboard[if clipboard.len() == 1 { 0 } else { target }]; Edit { target, start, end: start, text: Cow::Borrowed(text), caret: text.len(), round_up: true, } }) .collect(); self.mode = Mode::Insert; self.apply_edits(edits); self.clipboard = clipboard; for target in &mut self.targets { target.active.column = target.active.column.saturating_sub(1); } self.mode = Mode::Normal; self.normalize_targets(); self.record(before); self.message.clear(); } fn begin_insert(&mut self) { self.insert_start = Some(self.snapshot()); self.mode = Mode::Insert; } fn finish_insert(&mut self) { if let Some(before) = self.insert_start.take() { self.record(before); } } fn paste(&mut self, text: &str) { let validation = if text.len() > MAX_EDIT_BYTES { Err("Paste exceeds 4 MiB".into()) } else { validate_text(text) }; if let Err(error) = validation { self.set_message(error); return; } // Preserve the separate typing / paste / typing undo groups. self.finish_insert(); let before = (!text.is_empty()).then(|| self.snapshot()); self.insert_text(&normalize_newlines(text)); if let Some(before) = before { self.record(before); } self.insert_start = Some(self.snapshot()); } fn restore(&mut self, snapshot: Snapshot) { self.lines = snapshot.lines; self.targets = snapshot.targets; self.primary = snapshot.primary; self.mode = Mode::Normal; self.search_match = None; self.normalize_targets(); } fn undo(&mut self) { if let Some(before) = self.undo.pop() { self.redo.push(self.snapshot()); self.restore(before); } else { self.set_message("Nothing to undo"); } } fn redo(&mut self) { if let Some(after) = self.redo.pop() { self.undo.push(self.snapshot()); self.restore(after); } else { self.set_message("Nothing to redo"); } } fn move_targets(&mut self, action: Action) { for index in 0..self.targets.len() { let mut cursor = self.targets[index].active; match action { Action::Left => cursor.column = cursor.column.saturating_sub(1), Action::Right => { cursor.column = (cursor.column + 1).min(self.max_column(cursor.row)) } Action::Down => { cursor.row = (cursor.row + 1).min(self.lines.len() - 1); cursor.column = cursor.column.min(self.max_column(cursor.row)); } Action::Up => { cursor.row = cursor.row.saturating_sub(1); cursor.column = cursor.column.min(self.max_column(cursor.row)); } Action::LineStart => cursor.column = 0, Action::LineEnd => cursor.column = self.max_column(cursor.row), _ => cursor = self.word_destination(cursor, action), } self.targets[index].active = cursor; self.targets[index].preferred_columns = None; } self.normalize_targets(); } fn word_destination(&self, cursor: Cursor, action: Action) -> Cursor { let row = cursor.row; let byte = grapheme_byte(&self.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.lines[..=row], big) .rev() .skip_while(|(r, b, _)| (*r, *b) >= (row, byte)); tokens .find(|(_, _, kind)| *kind != WordKind::Whitespace) .map(|first| { if first.2 == WordKind::EmptyLine { first } else { tokens .take_while(|(_, _, kind)| *kind == first.2) .last() .unwrap_or(first) } }) .map(|(r, b, _)| (r, b)) .unwrap_or((0, 0)) } _ => { let mut tokens = word_tokens(&self.lines[row..], big) .skip_while(|(r, b, _)| (*r, *b) < (0, byte)); let current = tokens .next() .expect("cursor has a grapheme or line-end token") .2; let found = match action { Action::WordNext | Action::BigWordNext => tokens .skip_while(|(_, _, kind)| { *kind == current && current != WordKind::EmptyLine }) .find(|(_, _, kind)| *kind != WordKind::Whitespace), Action::WordEnd | Action::BigWordEnd => tokens .find(|(_, _, kind)| { !matches!(kind, WordKind::Whitespace | WordKind::EmptyLine) }) .map(|first| { tokens .take_while(|(_, _, kind)| *kind == first.2) .last() .unwrap_or(first) }), _ => unreachable!("word motion actions only"), }; found.map(|(r, b, _)| (row + r, b)).unwrap_or_else(|| { let last = self.lines.len() - 1; (last, self.lines[last].len()) }) } }; let line = &self.lines[destination.0]; let column = column_after_byte(line, destination.1); Cursor { row: destination.0, column: if destination.1 == line.len() && self.mode != Mode::Insert { column.saturating_sub(1) } else { column }, } } /// Every edit in a batch refers to this one pre-edit buffer. Duplicate row /// operations share an edit; caret destinations are remapped only afterwards. // ponytail: rebuilding text costs O(buffer + caretsยฒ); use per-line edits for large buffers. fn apply_edits(&mut self, mut edits: Vec>) { if edits.is_empty() { return; } edits.sort_by_key(|edit| (edit.start, edit.end)); let mut unique: Vec = Vec::with_capacity(edits.len()); for (index, edit) in edits.iter().enumerate() { if unique.last().is_some_and(|previous| { let previous = &edits[*previous]; previous.start == edit.start && previous.end == edit.end && previous.text == edit.text }) { continue; } unique.push(index); } let old = self.lines.join("\n"); let offsets = line_offsets(&self.lines); let removed: usize = unique .iter() .map(|index| edits[*index].end - edits[*index].start) .sum(); let added: usize = unique.iter().map(|index| edits[*index].text.len()).sum(); let mut text = String::with_capacity(old.len() - removed + added); let mut consumed = 0; for index in &unique { let edit = &edits[*index]; assert!(edit.start >= consumed, "batch edits must not overlap"); text.push_str(&old[consumed..edit.start]); text.push_str(&edit.text); consumed = edit.end; } text.push_str(&old[consumed..]); let remap = |position: usize, after: bool| { let mut removed = 0; let mut added = 0; for index in &unique { let edit = &edits[*index]; if position < edit.start { break; } if position == edit.start || position < edit.end { return edit.start - removed + added + if after { edit.text.len() } else { 0 }; } removed += edit.end - edit.start; added += edit.text.len(); } position - removed + added }; let mut destinations: Vec<_> = self .targets .iter() .map(|target| { ( remap(cursor_offset(&self.lines, &offsets, target.active), true), true, ) }) .collect(); for edit in &edits { destinations[edit.target] = (remap(edit.start, false) + edit.caret, edit.round_up); } if text != old { self.search_match = None; } self.lines = text.split('\n').map(str::to_owned).collect(); let offsets = line_offsets(&self.lines); for (target, (byte, round_up)) in self.targets.iter_mut().zip(destinations) { target.active = offset_cursor(&self.lines, &offsets, byte, round_up); target.anchor = target.active; target.preferred_columns = None; } self.normalize_targets(); } fn insert_text(&mut self, text: &str) { if text.is_empty() { return; } let offsets = line_offsets(&self.lines); let edits = self .targets .iter() .enumerate() .map(|(target, item)| { let start = cursor_offset(&self.lines, &offsets, item.active); Edit { target, start, end: start, text: Cow::Borrowed(text), caret: text.len(), round_up: true, } }) .collect(); self.apply_edits(edits); } fn open_lines(&mut self, below: bool) { let offsets = line_offsets(&self.lines); let edits = self .targets .iter() .enumerate() .map(|(target, item)| { let start = offsets[item.active.row] + if below { self.lines[item.active.row].len() } else { 0 }; Edit { target, start, end: start, text: Cow::Borrowed("\n"), caret: usize::from(below), round_up: true, } }) .collect(); self.apply_edits(edits); } fn delete_forward(&mut self) { let offsets = line_offsets(&self.lines); let edits = self .targets .iter() .enumerate() .filter_map(|(target, item)| { let cursor = item.active; let start = cursor_offset(&self.lines, &offsets, cursor); let end = if cursor.column < grapheme_count(&self.lines[cursor.row]) { cursor_offset(&self.lines, &offsets, self.next_boundary(cursor)) } else if self.mode == Mode::Insert && cursor.row + 1 < self.lines.len() { start + 1 } else { return None; }; Some(Edit { target, start, end, text: Cow::Borrowed(""), caret: 0, round_up: true, }) }) .collect(); self.apply_edits(edits); } fn join_lines(&mut self) { let before = self.snapshot(); let offsets = line_offsets(&self.lines); let edits = self .targets .iter() .enumerate() .filter_map(|(target, item)| { let row = item.active.row; let line = &self.lines[row]; let next = self.lines.get(row + 1)?; let trimmed = next.trim_start_matches([' ', '\t']); let separator = if !line.is_empty() && !trimmed.is_empty() && !line.ends_with([' ', '\t']) && !trimmed.starts_with(')') { if line.ends_with(['.', '?', '!']) { " " } else { " " } } else { "" }; let start = offsets[row] + line.len(); Some(Edit { target, start, end: start + 1 + next.len() - trimmed.len(), text: Cow::Borrowed(separator), caret: 0, round_up: false, }) }) .collect(); self.apply_edits(edits); self.record(before); } fn backspace(&mut self) { let offsets = line_offsets(&self.lines); let edits = self .targets .iter() .enumerate() .filter_map(|(target, item)| { let cursor = item.active; if cursor.column == 0 && cursor.row == 0 { return None; } let start = cursor_offset(&self.lines, &offsets, self.previous_boundary(cursor)); let end = cursor_offset(&self.lines, &offsets, cursor); Some(Edit { target, start, end, text: Cow::Borrowed(""), caret: 0, round_up: true, }) }) .collect(); self.apply_edits(edits); } } fn ranges_intersect(left: Selection, right: Selection) -> bool { if left.start == left.end { left.start == right.start || (right.start < left.start && left.start < right.end) } else if right.start == right.end { left.start <= right.start && right.start < left.end } else { left.start < right.end && right.start < left.end } } fn first_nonblank(line: &str) -> usize { line.graphemes(true) .take_while(|grapheme| matches!(*grapheme, " " | "\t")) .count() } fn display_column(line: &str, column: usize) -> usize { line.graphemes(true) .take(column) .fold(0, |cells, grapheme| { cells + if grapheme == "\t" { 4 - cells % 4 } else { grapheme.width() } }) } fn column_at_display(line: &str, wanted: usize, maximum: usize) -> usize { let mut cells = 0; let mut count = 0; for (column, grapheme) in line.graphemes(true).enumerate() { let width = if grapheme == "\t" { 4 - cells % 4 } else { grapheme.width() }; if cells + width > wanted { return column.min(maximum); } cells += width; count = column + 1; } count.min(maximum) } fn line_offsets(lines: &[String]) -> Vec { let mut byte = 0; lines .iter() .map(|line| { let start = byte; byte += line.len() + 1; start }) .collect() } fn cursor_offset(lines: &[String], offsets: &[usize], cursor: Cursor) -> usize { offsets[cursor.row] + grapheme_byte(&lines[cursor.row], cursor.column) } fn offset_cursor(lines: &[String], offsets: &[usize], byte: usize, round_up: bool) -> Cursor { let row = offsets .partition_point(|offset| *offset <= byte) .saturating_sub(1); let local = (byte - offsets[row]).min(lines[row].len()); let column = if round_up { column_after_byte(&lines[row], local) } else { lines[row] .grapheme_indices(true) .take_while(|(byte, _)| *byte <= local) .count() .saturating_sub(1) }; Cursor { row, column } } fn select_search_match( matches: impl Iterator, origin: (usize, usize), forward: bool, ) -> Option<(usize, usize)> { let mut wrapped = None; for position in matches { wrapped.get_or_insert(position); if (forward && position > origin) || (!forward && position < origin) { return Some(position); } } wrapped } #[derive(Clone, Copy, Eq, PartialEq)] enum WordKind { Whitespace, Keyword, Punctuation, EmptyLine, } fn word_tokens( lines: &[String], big: bool, ) -> impl DoubleEndedIterator + '_ { lines.iter().enumerate().flat_map(move |(row, line)| { line.grapheme_indices(true) .map(move |(byte, grapheme)| { let ch = grapheme.chars().next().expect("graphemes are nonempty"); let kind = if ch.is_whitespace() { WordKind::Whitespace } else if big || ch == '_' || ch.is_alphanumeric() { WordKind::Keyword } else { WordKind::Punctuation }; (row, byte, kind) }) .chain(std::iter::once(( row, line.len(), if line.is_empty() { WordKind::EmptyLine } else { WordKind::Whitespace }, ))) }) } fn valid_command(command: &str) -> bool { let command = command.strip_suffix('!').unwrap_or(command); let mut chars = command.chars(); chars.next().is_some_and(|ch| ch.is_ascii_alphabetic()) && chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-') } fn validate_text(text: &str) -> Result<(), String> { if let Some(ch) = text .chars() .find(|ch| ch.is_control() && !matches!(ch, '\t' | '\n' | '\r')) { Err(format!("Unsupported control character U+{:04X}", ch as u32)) } else { Ok(()) } } fn normalize_newlines(text: &str) -> Cow<'_, str> { if !text.contains('\r') { return Cow::Borrowed(text); } let mut normalized = String::with_capacity(text.len()); let mut chars = text.chars().peekable(); while let Some(ch) = chars.next() { if ch == '\r' { if chars.peek() == Some(&'\n') { chars.next(); } normalized.push('\n'); } else { normalized.push(ch); } } Cow::Owned(normalized) } fn split_lines(text: &str) -> Vec { normalize_newlines(text) .split('\n') .map(str::to_owned) .collect() } fn grapheme_count(line: &str) -> usize { line.graphemes(true).count() } fn grapheme_byte(line: &str, column: usize) -> usize { line.grapheme_indices(true) .nth(column) .map_or(line.len(), |(byte, _)| byte) } fn column_after_byte(line: &str, byte: usize) -> usize { line.grapheme_indices(true) .take_while(|(index, _)| *index < byte) .count() } fn target_metadata(path: &Path) -> io::Result> { match fs::symlink_metadata(path) { Ok(metadata) if metadata.file_type().is_symlink() => Err(io::Error::new( io::ErrorKind::InvalidInput, "Refusing to save through a symbolic link", )), Ok(metadata) if !metadata.is_file() => Err(io::Error::new( io::ErrorKind::InvalidInput, "Save target is not a regular file", )), Ok(metadata) => Ok(Some(metadata)), Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None), Err(error) => Err(error), } } fn atomic_save(path: &Path, lines: &[String]) -> io::Result<()> { let metadata = target_metadata(path)?; let parent = path .parent() .filter(|parent| !parent.as_os_str().is_empty()) .unwrap_or(Path::new(".")); let (temporary, mut file) = loop { let id = TEMP_ID.fetch_add(1, Ordering::Relaxed); let temporary = parent.join(format!(".vamp-{}-{id}.tmp", std::process::id())); let mut options = OpenOptions::new(); options.write(true).create_new(true); #[cfg(unix)] options.mode(0o600); match options.open(&temporary) { Ok(file) => break (temporary, file), Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue, Err(error) => return Err(error), } }; let result = (|| { for (index, line) in lines.iter().enumerate() { if index > 0 { file.write_all(b"\n")?; } file.write_all(line.as_bytes())?; } if let Some(metadata) = metadata { file.set_permissions(metadata.permissions())?; } file.sync_all()?; drop(file); // Recheck immediately before rename: never knowingly replace a symlink. target_metadata(path)?; fs::rename(&temporary, path)?; #[cfg(unix)] fs::File::open(parent)?.sync_all()?; Ok(()) })(); if result.is_err() { let _ = fs::remove_file(&temporary); } result } #[cfg(test)] mod tests { use super::*; struct Directory(PathBuf); impl Directory { fn new() -> Self { let id = TEMP_ID.fetch_add(1, Ordering::Relaxed); let path = std::env::temp_dir().join(format!("vamp-editor-test-{}-{id}", std::process::id())); fs::create_dir(&path).unwrap(); Self(path) } fn path(&self, name: &str) -> PathBuf { self.0.join(name) } } impl Drop for Directory { fn drop(&mut self) { let _ = fs::remove_dir_all(&self.0); } } fn editor() -> Editor { Editor::open(None, vec!["text".into()]).unwrap() } fn type_text(editor: &mut Editor, text: &str) { for ch in text.chars() { editor.handle(if ch == '\n' { Input::Enter } else { Input::Char(ch) }); } } fn 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.mode = Mode::Normal; editor.targets = positions .iter() .map(|&(row, column)| Target::caret(Cursor { row, column })) .collect(); editor.primary = primary; editor.normalize_targets(); } fn set_ranges(editor: &mut Editor, ranges: &[(Cursor, Cursor)], primary: usize) { editor.mode = Mode::Visual; editor.targets = ranges .iter() .map(|&(start, end)| editor.range_target(Selection { start, end }, true)) .collect(); editor.primary = primary; editor.normalize_targets(); } #[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.mode = Mode::Visual; editor.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.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.undo.len(); let redo = editor.redo.len(); editor.handle(Input::Char('p')); assert_eq!(editor.lines, before.lines); assert_eq!(editor.targets, before.targets); assert_eq!(editor.primary, before.primary); assert_eq!((editor.undo.len(), editor.redo.len()), (undo, redo)); assert!(editor.message().contains("fragment count")); editor.clipboard = vec!["!".into()]; set_carets(&mut editor, &[(0, 0), (1, 0)], 0); editor.handle(Input::Char('P')); assert_eq!(editor.context().text, "!b\n!q"); } #[test] fn literal_multiline_occurrences_skip_wrap_select_all_and_escape_in_two_stages() { let mut editor = editor(); editor .apply(vec![Effect::Replace("ฮฑ\nฮฒ ฮฑ\nฮฒ ฮฑ\nฮฒ".into())]) .unwrap(); set_ranges( &mut editor, &[(Cursor { row: 0, column: 0 }, Cursor { row: 1, column: 1 })], 0, ); editor.handle(Input::Ctrl('n')); assert_eq!(editor.selection_count(), 2); assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 }); editor.handle(Input::Ctrl('s')); assert_eq!(editor.selection_count(), 2); assert_eq!(editor.cursor(), Cursor { row: 3, column: 0 }); editor.handle(Input::Ctrl('n')); assert_eq!(editor.selection_count(), 3); assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 }); editor.handle(Input::Ctrl('a')); assert_eq!(editor.selection_count(), 3); editor.handle(Input::Escape); assert_eq!(editor.mode(), Mode::Normal); assert_eq!(editor.selection_count(), 3); let primary = editor.cursor(); editor.handle(Input::Escape); assert_eq!(editor.carets(), vec![primary]); } #[test] fn occurrences_reject_grapheme_intersections_and_gather_nonoverlapping_literals() { let mut editor = editor(); editor .apply(vec![Effect::Replace("aa aa aaa a\u{301}a".into())]) .unwrap(); editor.handle(Input::Char('v')); editor.handle(Input::Right); editor.handle(Input::Ctrl('a')); assert_eq!( editor .selections() .iter() .map(|range| range.start.column) .collect::>(), vec![0, 3, 6] ); assert_eq!(editor.cursor().column, 1); editor.handle(Input::Ctrl('n')); assert_eq!(editor.selection_count(), 3); } #[test] fn vertical_clones_keep_display_columns_through_tabs_wide_and_short_rows() { let mut editor = editor(); editor .apply(vec![Effect::Replace("a\t็•Œz\nx\n123456z".into())]) .unwrap(); editor.handle(Input::End); editor.handle(Input::Ctrl('e')); assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 }); editor.handle(Input::Ctrl('e')); assert_eq!(editor.selection_count(), 3); assert_eq!(editor.cursor(), Cursor { row: 2, column: 6 }); editor.handle(Input::Char('i')); type_text(&mut editor, "!"); editor.handle(Input::Escape); assert_eq!(editor.context().text, "a\t็•Œ!z\n!x\n123456!z"); assert_eq!(editor.selection_count(), 3); editor.handle(Input::Escape); assert_eq!(editor.selection_count(), 1); } #[test] fn visual_clones_preserve_directed_ranges_and_motion_targets_every_range() { let mut editor = editor(); editor .apply(vec![Effect::Replace("abc\nabcd\nabcde".into())]) .unwrap(); editor.handle(Input::Right); editor.handle(Input::Char('v')); editor.handle(Input::Right); editor.handle(Input::Ctrl('e')); assert_eq!(editor.selection_count(), 2); editor.handle(Input::Ctrl('e')); assert_eq!(editor.selection_count(), 3); editor.handle(Input::Left); assert!( editor .selections() .iter() .all(|range| range.start.column == 1 && range.end.column == 2) ); editor.handle(Input::Char('c')); type_text(&mut editor, "!"); editor.handle(Input::Escape); assert_eq!(editor.context().text, "a!c\na!cd\na!cde"); } #[test] fn batch_open_deduplicates_rows_and_join_uses_original_neighbor_rows() { for (key, expected) in [('o', "abc\n\nxyz\n"), ('O', "\nabc\n\nxyz")] { let mut editor = editor(); editor .apply(vec![Effect::Replace("abc\nxyz".into())]) .unwrap(); set_carets(&mut editor, &[(0, 0), (0, 2), (1, 1)], 2); editor.handle(Input::Char(key)); editor.handle(Input::Escape); assert_eq!(editor.context().text, expected); assert_eq!(editor.selection_count(), 2); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "abc\nxyz"); assert_eq!(editor.selection_count(), 3); } let mut editor = editor(); editor .apply(vec![Effect::Replace("a\n b\n c".into())]) .unwrap(); set_carets(&mut editor, &[(0, 0), (1, 1)], 1); editor.handle(Input::Char('J')); assert_eq!(editor.context().text, "a b c"); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "a\n b\n c"); assert_eq!(editor.selection_count(), 2); } #[test] fn plugin_text_effects_and_invocation_are_single_primary_but_messages_are_not() { let mut editor = editor(); editor .apply(vec![Effect::Replace("aa\nbb\ncc".into())]) .unwrap(); editor.register(plugin("sample", &["sample"])).unwrap(); set_carets(&mut editor, &[(0, 0), (1, 0), (2, 0)], 1); assert_eq!(editor.execute("sample").unwrap().command, "sample"); assert_eq!(editor.selection_count(), 3); editor.handle(Input::Char('v')); let ranges = editor.selections(); assert!(editor.apply(vec![Effect::Insert("\0".into())]).is_err()); editor.apply(vec![Effect::Message("ok".into())]).unwrap(); assert_eq!(editor.selections(), ranges); editor.apply(vec![Effect::Insert("!".into())]).unwrap(); assert_eq!(editor.context().text, "aa\n!bb\ncc"); assert_eq!(editor.selection_count(), 1); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "aa\nbb\ncc"); assert_eq!(editor.selection_count(), 3); assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 }); assert_eq!(editor.mode(), Mode::Normal); } #[test] fn literal_paste_broadcasts_and_retains_separate_typing_history_for_all_carets() { let mut editor = editor(); editor.apply(vec![Effect::Replace("a\nb".into())]).unwrap(); editor.handle(Input::Ctrl('e')); editor.handle(Input::Char('i')); type_text(&mut editor, "x"); editor.handle(Input::Paste("็•Œe\u{301}\r\n".into())); type_text(&mut editor, "z"); editor.handle(Input::Escape); assert_eq!(editor.context().text, "x็•Œe\u{301}\nza\nx็•Œe\u{301}\nzb"); for expected in ["x็•Œe\u{301}\na\nx็•Œe\u{301}\nb", "xa\nxb", "a\nb"] { editor.handle(Input::Char('u')); assert_eq!(editor.context().text, expected); assert_eq!(editor.selection_count(), 2); } } #[test] fn caret_motions_and_search_apply_to_all_targets_and_collapse_duplicates() { let mut editor = editor(); editor .apply(vec![Effect::Replace("cat cat\ncat cat".into())]) .unwrap(); set_carets(&mut editor, &[(0, 0), (1, 0), (1, 0)], 2); assert_eq!(editor.selection_count(), 2); assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 }); submit_search(&mut editor, "cat"); assert_eq!( editor.carets(), vec![Cursor { row: 0, column: 4 }, Cursor { row: 1, column: 4 }] ); editor.handle(Input::Char('b')); assert!(editor.carets().iter().all(|cursor| cursor.column == 0)); editor.handle(Input::Up); assert_eq!(editor.carets(), vec![Cursor::default()]); } #[test] fn word_motions_distinguish_punctuation_cross_empty_lines_and_preserve_history() { let mut editor = editor(); let text = "foo_bar...baz qux\n\n \n็•Œe\u{301}_2 ๐Ÿ‘ฉโ€๐Ÿš€!! tail"; editor.apply(vec![Effect::Replace(text.into())]).unwrap(); for (key, row, column) in [ ('w', 0, 7), ('w', 0, 10), ('w', 0, 15), ('w', 1, 0), ('w', 3, 0), ('w', 3, 5), ('w', 3, 9), ('w', 3, 12), ('w', 3, 12), ('b', 3, 9), ('b', 3, 5), ('b', 3, 0), ('b', 1, 0), ('b', 0, 15), ('b', 0, 10), ('b', 0, 7), ('b', 0, 0), ('b', 0, 0), ('e', 0, 6), ('e', 0, 9), ('e', 0, 12), ('e', 0, 17), ('e', 3, 3), ('e', 3, 7), ('e', 3, 12), ('e', 3, 12), ] { editor.handle(Input::Char(key)); assert_eq!(editor.cursor(), Cursor { row, column }, "{key}"); } assert_eq!(editor.context().text, text); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, ""); editor.handle(Input::Char('U')); assert_eq!(editor.context().text, text); } #[test] fn big_word_motions_use_only_whitespace_and_handle_buffer_edges() { let mut editor = editor(); editor .apply(vec![Effect::Replace( "foo_bar...baz qux\n\n \n็•Œe\u{301}_2 ๐Ÿ‘ฉโ€๐Ÿš€!! tail".into(), )]) .unwrap(); for (key, row, column) in [ ('W', 0, 15), ('W', 1, 0), ('W', 3, 0), ('W', 3, 5), ('W', 3, 9), ('W', 3, 12), ('B', 3, 9), ('B', 3, 5), ('B', 3, 0), ('B', 1, 0), ('B', 0, 15), ('B', 0, 0), ('B', 0, 0), ('E', 0, 12), ('E', 0, 17), ('E', 3, 3), ('E', 3, 7), ('E', 3, 12), ('E', 3, 12), ] { editor.handle(Input::Char(key)); assert_eq!(editor.cursor(), Cursor { row, column }, "{key}"); } for (text, next, end) in [("", 0, 0), (" ", 2, 2), ("word ", 6, 3)] { editor.apply(vec![Effect::Replace(text.into())]).unwrap(); editor.handle(Input::Home); editor.handle(Input::Char('e')); assert_eq!( editor.cursor(), Cursor { row: 0, column: end } ); editor.handle(Input::Char('w')); assert_eq!( editor.cursor(), Cursor { row: 0, column: next } ); editor.handle(Input::Char('b')); assert_eq!(editor.cursor(), Cursor { row: 0, column: 0 }); } editor .apply(vec![Effect::Replace("one\u{2003}two\u{a0}three\n".into())]) .unwrap(); editor.handle(Input::Home); editor.handle(Input::Char('w')); assert_eq!(editor.cursor(), Cursor { row: 0, column: 4 }); editor.handle(Input::Char('w')); assert_eq!(editor.cursor(), Cursor { row: 0, column: 8 }); editor.handle(Input::Char('w')); assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 }); editor.handle(Input::Char('e')); assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 }); editor.handle(Input::Char('b')); assert_eq!(editor.cursor(), Cursor { row: 0, column: 8 }); } #[test] fn search_wraps_both_ways_and_cancel_failure_and_navigation_preserve_edits() { let mut editor = editor(); let text = "zero cat\ncat cat\nCAT\n"; editor.apply(vec![Effect::Replace(text.into())]).unwrap(); submit_search(&mut editor, "cat"); assert_eq!(editor.cursor(), Cursor { row: 0, column: 5 }); assert_eq!(editor.search_match().unwrap().end_column, 8); editor.handle(Input::Char('n')); assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 }); editor.handle(Input::Char('n')); assert_eq!(editor.cursor(), Cursor { row: 1, column: 4 }); editor.handle(Input::Char('n')); assert_eq!(editor.cursor(), Cursor { row: 0, column: 5 }); editor.handle(Input::Char('N')); assert_eq!(editor.cursor(), Cursor { row: 1, column: 4 }); editor.handle(Input::Char('N')); assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 }); let cursor = editor.cursor(); let active = editor.search_match(); editor.handle(Input::Char('/')); type_text(&mut editor, "other็•Œe\u{301}"); editor.handle(Input::Backspace); assert_eq!(editor.command_line(), "other็•Œ"); editor.handle(Input::Paste(":q!\n".into())); assert_eq!(editor.command_line(), "other็•Œ"); editor.handle(Input::Escape); assert_eq!(editor.mode(), Mode::Normal); assert_eq!(editor.cursor(), cursor); assert_eq!(editor.search_match(), active); assert!(editor.command_line().is_empty()); submit_search(&mut editor, ""); assert_eq!(editor.cursor(), Cursor { row: 1, column: 4 }); let cursor = editor.cursor(); submit_search(&mut editor, "missing"); assert_eq!(editor.mode(), Mode::Normal); assert_eq!(editor.cursor(), cursor); assert_eq!(editor.search_match(), None); editor.handle(Input::Char('N')); assert_eq!(editor.cursor(), cursor); assert!(!editor.should_quit()); assert_eq!(editor.context().text, text); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, ""); editor.handle(Input::Char('U')); assert_eq!(editor.context().text, text); } #[test] fn search_is_literal_overlapping_and_grapheme_safe_and_rechecks_after_edits() { let mut editor = editor(); editor .apply(vec![Effect::Replace("e\u{301} e ๐Ÿ‘ฉโ€๐Ÿš€ ๐Ÿ‘ฉ aa... aaa".into())]) .unwrap(); submit_search(&mut editor, "e"); assert_eq!(editor.cursor().column, 2); submit_search(&mut editor, "\u{301}"); assert_eq!(editor.cursor().column, 2); assert_eq!(editor.search_match(), None); submit_search(&mut editor, "๐Ÿ‘ฉ"); assert_eq!(editor.cursor().column, 6); submit_search(&mut editor, "๐Ÿ‘ฉโ€๐Ÿš€"); assert_eq!(editor.cursor().column, 4); assert_eq!(editor.search_match().unwrap().end_column, 5); submit_search(&mut editor, "."); assert_eq!(editor.cursor().column, 10); editor.handle(Input::Char('n')); assert_eq!(editor.cursor().column, 11); submit_search(&mut editor, "aa"); assert_eq!(editor.cursor().column, 14); editor.handle(Input::Char('n')); assert_eq!(editor.cursor().column, 15); editor.handle(Input::Char('n')); assert_eq!(editor.cursor().column, 8); submit_search(&mut editor, "e\u{301}"); assert_eq!(editor.cursor().column, 0); assert_eq!(editor.search_match().unwrap().end_column, 1); let active = editor.search_match(); assert!(editor.apply(vec![Effect::Replace("\0".into())]).is_err()); assert_eq!(editor.search_match(), active); editor.handle(Input::Char('x')); assert_eq!(editor.search_match(), None); editor.handle(Input::Char('n')); assert_eq!(editor.search_match(), None); editor.handle(Input::Char('u')); assert_eq!(editor.search_match(), None); editor.handle(Input::Char('n')); assert_eq!(editor.cursor().column, 0); assert_eq!(editor.search_match().unwrap().end_column, 1); editor .apply(vec![Effect::Replace("e\u{301}".into())]) .unwrap(); assert_eq!(editor.search_match(), None); editor.handle(Input::Char('n')); assert_eq!(editor.cursor().column, 0); assert_eq!(editor.search_match().unwrap().end_column, 1); } #[test] fn join_lines_matches_vim_spacing_and_grapheme_cursor_positions() { for (original, expected, column) in [ ("alpha\n \tbeta\ntail", "alpha beta\ntail", 5), ("alpha \n\tbeta", "alpha beta", 7), ("alpha\t\n beta", "alpha\tbeta", 6), ("\n beta", "beta", 0), ("alpha\n \t", "alpha", 4), ("\n", "", 0), ("alpha\n )tail", "alpha)tail", 5), ("Sentence.\n next", "Sentence. next", 9), ("็•Œe\u{301}\n next", "็•Œe\u{301} next", 2), ("x \n\u{301}y", "x \u{301}y", 1), ] { let mut editor = editor(); editor .apply(vec![Effect::Replace(original.into())]) .unwrap(); editor.handle(Input::Char('J')); assert_eq!(editor.context().text, expected); assert_eq!(editor.cursor(), Cursor { row: 0, column }); assert_eq!(editor.mode(), Mode::Normal); } } #[test] fn join_is_one_undo_action_on_the_current_row_and_restores_cursor() { let mut editor = editor(); let original = "above\nalpha\n beta\nbelow"; editor .apply(vec![Effect::Replace(original.into())]) .unwrap(); editor.handle(Input::Down); editor.handle(Input::Right); let before = editor.cursor(); editor.handle(Input::Char('J')); assert_eq!(editor.context().text, "above\nalpha beta\nbelow"); assert_eq!(editor.cursor(), Cursor { row: 1, column: 5 }); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, original); assert_eq!(editor.cursor(), before); editor.handle(Input::Char('U')); assert_eq!(editor.context().text, "above\nalpha beta\nbelow"); editor.handle(Input::Char('x')); assert_eq!(editor.context().text, "above\nalphabeta\nbelow"); } #[test] fn join_at_eof_preserves_history_and_insert_mode_j_is_literal() { let mut editor = editor(); editor .apply(vec![Effect::Replace("single".into())]) .unwrap(); editor.handle(Input::Char('u')); editor.handle(Input::Char('J')); assert_eq!(editor.context().text, ""); editor.handle(Input::Char('U')); assert_eq!(editor.context().text, "single"); editor.handle(Input::Char('J')); assert_eq!(editor.context().text, "single"); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, ""); editor.handle(Input::Char('U')); editor.handle(Input::Char('i')); editor.handle(Input::Char('J')); editor.handle(Input::Escape); assert_eq!(editor.context().text, "Jsingle"); } #[test] fn uppercase_u_redoes_and_control_r_no_longer_does() { let mut editor = editor(); editor.handle(Input::Char('i')); type_text(&mut editor, "changed"); editor.handle(Input::Escape); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, ""); editor.handle(Input::Ctrl('r')); assert_eq!(editor.context().text, ""); editor.handle(Input::Char('U')); assert_eq!(editor.context().text, "changed"); } #[test] fn uppercase_insert_keys_target_line_positions_and_preserve_undo_cursor() { for (original, key, expected) in [ (" \t็•Œe\u{301} tail", 'I', " \tX็•Œe\u{301} tail"), (" \t ", 'I', " \t X"), ("", 'I', "X"), (" \t็•Œe\u{301} tail", 'A', " \t็•Œe\u{301} tailX"), ("", 'A', "X"), ] { let mut editor = editor(); editor .apply(vec![Effect::Replace(original.into())]) .unwrap(); editor.handle(if key == 'I' { Input::End } else { Input::Right }); let before = editor.cursor(); editor.handle(Input::Char(key)); assert_eq!(editor.mode(), Mode::Insert); type_text(&mut editor, "X"); editor.handle(Input::Escape); assert_eq!(editor.context().text, expected); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, original); assert_eq!(editor.cursor(), before); editor.handle(Input::Char('U')); assert_eq!(editor.context().text, expected); } } #[test] fn paste_is_ignored_in_normal_and_command_modes_without_dispatching() { let mut editor = editor(); assert_eq!(editor.handle(Input::Paste(":q!\r\n".into())), None); assert_eq!(editor.mode(), Mode::Normal); assert_eq!(editor.context().text, ""); assert!(!editor.dirty()); assert!(!editor.should_quit()); editor.handle(Input::Char(':')); type_text(&mut editor, "q"); assert_eq!(editor.handle(Input::Paste("\rq!\n\0".into())), None); assert_eq!(editor.mode(), Mode::Command); assert_eq!(editor.command_line(), "q"); assert_eq!(editor.context().text, ""); assert!(!editor.should_quit()); editor.handle(Input::Enter); assert!(editor.should_quit()); } #[test] fn pasted_unicode_and_newlines_are_literal_and_a_separate_undo_action() { let mut editor = editor(); editor.handle(Input::Char('i')); type_text(&mut editor, "a"); assert_eq!( editor.handle(Input::Paste("็•Œe\u{301}\t\r\n:q!\r".into())), None ); assert_eq!(editor.mode(), Mode::Insert); assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 }); type_text(&mut editor, "z"); editor.handle(Input::Escape); assert_eq!(editor.context().text, "a็•Œe\u{301}\t\n:q!\nz"); assert!(!editor.should_quit()); for expected in ["a็•Œe\u{301}\t\n:q!\n", "a", ""] { editor.handle(Input::Char('u')); assert_eq!(editor.context().text, expected); } for expected in ["a", "a็•Œe\u{301}\t\n:q!\n", "a็•Œe\u{301}\t\n:q!\nz"] { editor.handle(Input::Char('U')); assert_eq!(editor.context().text, expected); } } #[test] fn rejected_paste_preserves_text_cursor_mode_and_pending_insert_history() { for text in [ "valid prefix\ninvalid\0".to_owned(), "\u{1b}[31m".to_owned(), "x".repeat(MAX_EDIT_BYTES + 1), ] { let mut editor = editor(); editor.handle(Input::Char('i')); type_text(&mut editor, "before"); let cursor = editor.cursor(); editor.handle(Input::Paste(text)); assert_eq!(editor.context().text, "before"); assert_eq!(editor.cursor(), cursor); assert_eq!(editor.mode(), Mode::Insert); assert!( editor.message().contains("Unsupported control character") || editor.message().contains("Paste exceeds 4 MiB") ); type_text(&mut editor, "after"); editor.handle(Input::Escape); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, ""); editor.handle(Input::Char('U')); assert_eq!(editor.context().text, "beforeafter"); } } #[test] fn insert_sessions_and_open_lines_are_single_undo_actions() { let mut editor = editor(); editor.handle(Input::Char('i')); type_text(&mut editor, "hello\nไธ–็•Œ"); editor.handle(Input::Escape); assert_eq!(editor.context().text, "hello\nไธ–็•Œ"); assert!(editor.dirty()); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, ""); assert!(!editor.dirty()); editor.handle(Input::Char('U')); assert_eq!(editor.context().text, "hello\nไธ–็•Œ"); editor.handle(Input::Char('O')); type_text(&mut editor, "above"); editor.handle(Input::Escape); assert_eq!(editor.context().text, "hello\nabove\nไธ–็•Œ"); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "hello\nไธ–็•Œ"); editor.handle(Input::Char('o')); type_text(&mut editor, "below"); editor.handle(Input::Escape); assert_eq!(editor.context().text, "hello\nไธ–็•Œ\nbelow"); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "hello\nไธ–็•Œ"); } #[test] fn grapheme_navigation_and_deletion_do_not_split_unicode() { let mut editor = editor(); editor .apply(vec![Effect::Replace("a\u{301}๐Ÿ‘ฉโ€๐Ÿ’ป็•Œ".into())]) .unwrap(); editor.handle(Input::Char('l')); assert_eq!(editor.cursor().column, 1); editor.handle(Input::Char('x')); assert_eq!(editor.context().text, "a\u{301}็•Œ"); editor.handle(Input::Char('0')); editor.handle(Input::Delete); assert_eq!(editor.context().text, "็•Œ"); editor.handle(Input::Char('a')); type_text(&mut editor, "๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘ง"); editor.handle(Input::Backspace); assert_eq!(editor.context().text, "็•Œ"); editor.handle(Input::Escape); assert_eq!(editor.cursor().column, 0); assert_eq!(editor.context().column, 1); } #[test] fn insertion_merges_combining_graphemes_and_joins_lines() { let mut editor = editor(); editor.handle(Input::Char('i')); type_text(&mut editor, "a\u{301}\nb"); editor.handle(Input::Home); editor.handle(Input::Backspace); assert_eq!(editor.context().text, "a\u{301}b"); assert_eq!(editor.cursor(), Cursor { row: 0, column: 1 }); editor.handle(Input::Enter); editor.handle(Input::Up); editor.handle(Input::End); editor.handle(Input::Delete); assert_eq!(editor.context().text, "a\u{301}b"); editor.handle(Input::Escape); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, ""); } #[test] fn cursor_is_clamped_on_short_and_empty_lines() { let mut editor = editor(); editor .apply(vec![Effect::Replace("long\nx\n".into())]) .unwrap(); editor.handle(Input::End); assert_eq!(editor.cursor().column, 3); editor.handle(Input::Down); assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 }); editor.handle(Input::Char('j')); editor.handle(Input::Char('l')); editor.handle(Input::Char('x')); assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 }); assert_eq!(editor.context().row, 3); editor.handle(Input::Char('i')); editor.handle(Input::Left); editor.handle(Input::Delete); editor.handle(Input::Escape); assert_eq!(editor.context().text, "long\nx\n"); } #[test] fn registry_rejects_incompatible_duplicate_and_forged_core_atomically() { let mut editor = editor(); let mut feature = plugin("graphics", &["draw"]); feature.required_capabilities.push("graphics".into()); assert!(editor.register(feature).is_err()); assert!(editor.bind("normal", "g", Some("draw".into())).is_err()); assert!( editor .register(plugin("duplicate", &["newcmd", "w"])) .is_err() ); assert!(editor.bind("normal", "g", Some("newcmd".into())).is_err()); assert!( editor .register(plugin("repeat", &["same", "same"])) .is_err() ); assert!(editor.register(plugin("invalid", &["bad name"])).is_err()); assert!(editor.register(plugin("", &["emptyname"])).is_err()); let mut feature = plugin("forged", &["override"]); feature.core = true; assert!(editor.register(feature).is_err()); editor.register(plugin("working", &["greet"])).unwrap(); assert!(editor.register(plugin("working", &["different"])).is_err()); assert!(editor.register(plugin("other", &["greet"])).is_err()); assert!(editor.register(plugin("collision", &["plugins"])).is_err()); editor.disable_plugin("core", "attempt"); editor.execute("q"); assert!(editor.should_quit()); } #[test] fn plugins_command_lists_active_features_and_capabilities_deterministically() { let mut editor = editor(); editor.register(plugin("zebra", &["last"])).unwrap(); editor.register(plugin("alpha", &["first"])).unwrap(); editor.execute("plugins"); assert_eq!( editor.message(), "Capabilities: text | alpha: first | core [core]: w, q, q!, wq, plugins | zebra: last" ); editor.disable_plugin("alpha", "fault"); editor.execute("plugins"); assert!(!editor.message().contains("alpha")); assert!(editor.message().contains("core [core]")); } #[test] fn readable_key_aliases_share_bindings_and_invalid_chords_leave_them_intact() { let mut editor = editor(); editor.bind("normal", "shift+i", None).unwrap(); editor.handle(Input::Char('I')); assert_eq!(editor.mode(), Mode::Normal); editor .bind("normal", "I", Some("core.insert".into())) .unwrap(); editor.handle(Input::Char('I')); assert_eq!(editor.mode(), Mode::Insert); editor .bind("insert", "plus", Some("core.newline".into())) .unwrap(); editor.bind("insert", "+", None).unwrap(); editor.handle(Input::Char('+')); type_text(&mut editor, "็•Œ"); assert_eq!(editor.lines(), &["็•Œ"]); editor .bind("insert", "plus", Some("core.newline".into())) .unwrap(); editor.handle(Input::Char('+')); assert_eq!(editor.lines(), &["็•Œ", ""]); editor.bind("insert", "CTRL+I", None).unwrap(); editor.handle(Input::Char('\t')); assert_eq!(editor.lines(), &["็•Œ", ""]); editor .bind("insert", "TAB", Some("core.newline".into())) .unwrap(); editor.handle(Input::Ctrl('i')); assert_eq!(editor.lines(), &["็•Œ", "", ""]); editor .bind("insert", "shift+j", Some("core.normal".into())) .unwrap(); for key in [ "", "", "", "ctrl+", "+r", "ctrl++r", "ctrl+shift+j", "shift+tab", "shift+1", "alt+j", "cmd+j", "ctrl+็•Œ", ] { assert!( editor .bind("insert", key, Some("core.newline".into())) .is_err(), "{key}" ); } editor.handle(Input::Char('J')); assert_eq!(editor.mode(), Mode::Normal); editor.handle(Input::Char('u')); assert_eq!(editor.lines(), &[String::new()]); } #[test] fn plugin_bindings_dispatch_only_normal_mode_and_retire_with_owner() { let mut editor = editor(); editor.register(plugin("greeting", &["greet"])).unwrap(); assert!(editor.bind("insert", "g", Some("greet".into())).is_err()); editor.bind("normal", "g", Some("greet".into())).unwrap(); assert_eq!( editor.handle(Input::Char('g')), Some(Invocation { plugin: "greeting".into(), command: "greet".into(), args: String::new(), }) ); editor.handle(Input::Char('i')); assert_eq!(editor.handle(Input::Char('g')), None); assert_eq!(editor.context().text, "g"); editor.handle(Input::Escape); editor.disable_plugin("greeting", "crashed\u{1b}[31m"); assert_eq!(editor.handle(Input::Char('g')), None); assert!(editor.bind("normal", "g", Some("greet".into())).is_err()); assert!(!editor.message().contains('\u{1b}')); } #[test] fn command_mode_dispatches_arguments_and_backspaces_whole_graphemes() { let mut editor = editor(); editor.register(plugin("greeting", &["greet"])).unwrap(); editor.handle(Input::Char(':')); type_text(&mut editor, "greet a\u{301}"); editor.handle(Input::Backspace); assert_eq!(editor.command_line(), "greet "); type_text(&mut editor, "world"); assert_eq!( editor.handle(Input::Enter), Some(Invocation { plugin: "greeting".into(), command: "greet".into(), args: "world".into(), }) ); assert_eq!(editor.mode(), Mode::Normal); assert_eq!(editor.command_line(), ""); editor.handle(Input::Char(':')); type_text(&mut editor, "q!"); editor.handle(Input::Escape); assert!(!editor.should_quit()); assert_eq!(editor.command_line(), ""); } #[test] fn plugin_effects_validate_before_any_mutation_and_group_undo() { let mut editor = editor(); editor.set_message("unchanged"); assert!( editor .apply(vec![ Effect::Insert("good".into()), Effect::Message("bad\u{1b}".into()) ]) .is_err() ); assert_eq!(editor.context().text, ""); assert_eq!(editor.message(), "unchanged"); assert!(!editor.dirty()); assert!( editor .apply(vec![ Effect::Insert("a".repeat(MAX_EDIT_BYTES)), Effect::Insert("x".into()) ]) .is_err() ); assert_eq!(editor.context().text, ""); editor .apply(vec![ Effect::Insert("one".into()), Effect::Insert("\ntwo".into()), Effect::Message("ok".into()), ]) .unwrap(); assert_eq!(editor.context().text, "one\ntwo"); assert_eq!(editor.message(), "ok"); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, ""); editor.handle(Input::Char('U')); assert_eq!(editor.context().text, "one\ntwo"); editor .apply(vec![Effect::Replace("replacement".into())]) .unwrap(); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "one\ntwo"); } #[test] fn plugin_effect_transactions_preserve_insert_sessions() { let mut editor = editor(); editor.handle(Input::Char('i')); type_text(&mut editor, "before"); let cursor = editor.cursor(); assert!( editor .apply(vec![Effect::Replace("invalid\0".into())]) .is_err() ); assert_eq!(editor.mode(), Mode::Insert); assert_eq!(editor.cursor(), cursor); type_text(&mut editor, "after"); editor.handle(Input::Escape); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, ""); editor.handle(Input::Char('i')); type_text(&mut editor, "a"); editor .apply(vec![ Effect::Insert("b".into()), Effect::Message("plugin".into()), ]) .unwrap(); type_text(&mut editor, "c"); editor.handle(Input::Escape); assert_eq!(editor.context().text, "abc"); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "ab"); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "a"); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, ""); } #[test] fn no_op_edits_preserve_redo_but_new_edits_clear_it() { let mut editor = editor(); editor.apply(vec![Effect::Insert("a".into())]).unwrap(); editor.handle(Input::Char('u')); editor.handle(Input::Char('i')); editor.handle(Input::Escape); editor .apply(vec![Effect::Message("notice".into())]) .unwrap(); editor.handle(Input::Char('U')); assert_eq!(editor.context().text, "a"); editor.handle(Input::Char('u')); editor.apply(vec![Effect::Insert("b".into())]).unwrap(); editor.handle(Input::Char('U')); assert_eq!(editor.context().text, "b"); } #[test] fn saves_preserve_empty_files_lf_trailing_newlines_and_dirty_history() { let directory = Directory::new(); for (name, text) in [ ("empty", ""), ("no-newline", "hello"), ("newline", "hello\n"), ("two-newlines", "hello\n\n"), ] { let path = directory.path(name); fs::write(&path, text).unwrap(); let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); editor.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.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 .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 .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); } }