Files
vamp/src/editor.rs
T
2026-10-09 12:25:25 +02:00

9759 lines
368 KiB
Rust
Raw Blame History

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