4223 lines
148 KiB
Rust
4223 lines
148 KiB
Rust
use serde::Serialize;
|
||
use std::borrow::Cow;
|
||
use std::collections::{HashMap, HashSet};
|
||
use std::fmt;
|
||
use std::fs::{self, OpenOptions};
|
||
use std::io::{self, Write};
|
||
#[cfg(unix)]
|
||
use std::os::unix::fs::OpenOptionsExt;
|
||
use std::path::{Path, PathBuf};
|
||
use std::sync::atomic::{AtomicU64, Ordering};
|
||
use unicode_segmentation::{GraphemeCursor, UnicodeSegmentation};
|
||
use unicode_width::UnicodeWidthStr;
|
||
|
||
const MAX_EDIT_BYTES: usize = 4 * 1024 * 1024;
|
||
static TEMP_ID: AtomicU64 = AtomicU64::new(0);
|
||
|
||
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
|
||
pub enum Input {
|
||
Char(char),
|
||
Ctrl(char),
|
||
Paste(String),
|
||
Escape,
|
||
Enter,
|
||
Backspace,
|
||
Delete,
|
||
Left,
|
||
Right,
|
||
Up,
|
||
Down,
|
||
Home,
|
||
End,
|
||
}
|
||
|
||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||
pub enum Mode {
|
||
Normal,
|
||
Insert,
|
||
Command,
|
||
Search,
|
||
Visual,
|
||
}
|
||
|
||
impl fmt::Display for Mode {
|
||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||
f.write_str(match self {
|
||
Self::Normal => "NORMAL",
|
||
Self::Insert => "INSERT",
|
||
Self::Command => "COMMAND",
|
||
Self::Search => "SEARCH",
|
||
Self::Visual => "VISUAL",
|
||
})
|
||
}
|
||
}
|
||
|
||
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 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,
|
||
}
|
||
}
|
||
}
|
||
|
||
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,
|
||
}
|
||
|
||
#[derive(Clone, Copy, Eq, PartialEq)]
|
||
enum Action {
|
||
Insert,
|
||
InsertFirstNonblank,
|
||
Append,
|
||
AppendLineEnd,
|
||
OpenBelow,
|
||
OpenAbove,
|
||
Left,
|
||
Right,
|
||
Down,
|
||
Up,
|
||
JoinLines,
|
||
LineStart,
|
||
LineEnd,
|
||
Delete,
|
||
Undo,
|
||
Redo,
|
||
Command,
|
||
Normal,
|
||
Newline,
|
||
Backspace,
|
||
Submit,
|
||
Search,
|
||
SearchNext,
|
||
SearchPrevious,
|
||
WordNext,
|
||
WordPrevious,
|
||
WordEnd,
|
||
BigWordNext,
|
||
BigWordPrevious,
|
||
BigWordEnd,
|
||
Select,
|
||
Copy,
|
||
Cut,
|
||
Change,
|
||
PasteBefore,
|
||
PasteAfter,
|
||
SelectLines,
|
||
CloneAbove,
|
||
CloneBelow,
|
||
SelectNext,
|
||
SkipOccurrence,
|
||
SelectAll,
|
||
Palette,
|
||
}
|
||
|
||
// 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.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.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.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,
|
||
],
|
||
),
|
||
];
|
||
|
||
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() {
|
||
'h' => Input::Backspace,
|
||
'i' => Input::Char('\t'),
|
||
'j' | 'm' => Input::Enter,
|
||
ch => Input::Ctrl(ch),
|
||
}
|
||
}
|
||
|
||
fn display_key(input: &Input) -> String {
|
||
match input {
|
||
Input::Char('\t') => "Tab".into(),
|
||
Input::Char(' ') => "Space".into(),
|
||
Input::Char(ch) => ch.to_string(),
|
||
Input::Ctrl(ch) => format!("Ctrl+{ch}"),
|
||
Input::Escape => "Esc".into(),
|
||
Input::Enter => "Enter".into(),
|
||
Input::Backspace => "Backspace".into(),
|
||
Input::Delete => "Delete".into(),
|
||
Input::Left => "Left".into(),
|
||
Input::Right => "Right".into(),
|
||
Input::Up => "Up".into(),
|
||
Input::Down => "Down".into(),
|
||
Input::Home => "Home".into(),
|
||
Input::End => "End".into(),
|
||
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),
|
||
] {
|
||
bindings
|
||
.entry(mode)
|
||
.or_default()
|
||
.insert(Input::Char(key), Binding::Action(action));
|
||
}
|
||
for (key, action) in [
|
||
(Input::Ctrl('k'), CloneAbove),
|
||
(Input::Ctrl('e'), CloneBelow),
|
||
(Input::Escape, Normal),
|
||
] {
|
||
bindings
|
||
.entry(mode)
|
||
.or_default()
|
||
.insert(key, Binding::Action(action));
|
||
}
|
||
}
|
||
for mode in [Mode::Normal, Mode::Insert, Mode::Visual] {
|
||
for (key, action) in [
|
||
(Input::Left, Left),
|
||
(Input::Right, Right),
|
||
(Input::Down, Down),
|
||
(Input::Up, Up),
|
||
(Input::Home, LineStart),
|
||
(Input::End, LineEnd),
|
||
] {
|
||
bindings
|
||
.entry(mode)
|
||
.or_default()
|
||
.insert(key, Binding::Action(action));
|
||
}
|
||
}
|
||
for mode in [Mode::Normal, Mode::Insert] {
|
||
bindings
|
||
.entry(mode)
|
||
.or_default()
|
||
.insert(Input::Delete, Binding::Action(Delete));
|
||
}
|
||
bindings
|
||
.entry(Mode::Visual)
|
||
.or_default()
|
||
.insert(Input::Escape, Binding::Action(Normal));
|
||
for (key, action) in [
|
||
(Input::Char('y'), Copy),
|
||
(Input::Char('d'), Cut),
|
||
(Input::Char('c'), Change),
|
||
(Input::Char('x'), Cut),
|
||
(Input::Delete, Cut),
|
||
(Input::Char('I'), InsertFirstNonblank),
|
||
(Input::Char('A'), AppendLineEnd),
|
||
(Input::Ctrl('l'), SelectLines),
|
||
(Input::Ctrl('n'), SelectNext),
|
||
(Input::Ctrl('s'), SkipOccurrence),
|
||
(Input::Ctrl('a'), SelectAll),
|
||
] {
|
||
bindings
|
||
.entry(Mode::Visual)
|
||
.or_default()
|
||
.insert(key, Binding::Action(action));
|
||
}
|
||
for mode in [Mode::Insert, Mode::Command, Mode::Search] {
|
||
for (key, action) in [
|
||
(Input::Escape, Normal),
|
||
(Input::Backspace, Backspace),
|
||
(
|
||
Input::Enter,
|
||
if mode == Mode::Insert {
|
||
Newline
|
||
} else {
|
||
Submit
|
||
},
|
||
),
|
||
] {
|
||
bindings
|
||
.entry(mode)
|
||
.or_default()
|
||
.insert(key, Binding::Action(action));
|
||
}
|
||
}
|
||
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
|
||
}
|
||
|
||
pub struct Editor {
|
||
lines: Vec<String>,
|
||
saved_lines: Vec<String>,
|
||
targets: Vec<Target>,
|
||
primary: usize,
|
||
mode: Mode,
|
||
command_line: String,
|
||
search_query: String,
|
||
search_match: Option<SearchMatch>,
|
||
clipboard: Vec<String>,
|
||
message: String,
|
||
path: Option<PathBuf>,
|
||
quit: bool,
|
||
capabilities: Vec<String>,
|
||
features: HashMap<String, Feature>,
|
||
bindings: HashMap<Mode, HashMap<Input, Binding>>,
|
||
palette: Option<Palette>,
|
||
// 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>,
|
||
}
|
||
|
||
impl Editor {
|
||
pub fn open(path: Option<PathBuf>, capabilities: Vec<String>) -> io::Result<Self> {
|
||
let text = match &path {
|
||
Some(path) => match fs::read_to_string(path) {
|
||
Ok(text) => text,
|
||
Err(error) if error.kind() == io::ErrorKind::NotFound => String::new(),
|
||
Err(error) => return Err(error),
|
||
},
|
||
None => String::new(),
|
||
};
|
||
validate_text(&text).map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
|
||
let lines = split_lines(&text);
|
||
let mut editor = Self {
|
||
saved_lines: lines.clone(),
|
||
lines,
|
||
targets: vec![Target::caret(Cursor::default())],
|
||
primary: 0,
|
||
mode: Mode::Normal,
|
||
command_line: String::new(),
|
||
search_query: String::new(),
|
||
search_match: None,
|
||
clipboard: Vec::new(),
|
||
message: String::new(),
|
||
path,
|
||
quit: false,
|
||
capabilities,
|
||
features: HashMap::new(),
|
||
bindings: default_bindings(),
|
||
palette: None,
|
||
undo: Vec::new(),
|
||
redo: Vec::new(),
|
||
insert_start: None,
|
||
};
|
||
editor
|
||
.install(
|
||
Feature {
|
||
name: "core".into(),
|
||
required_capabilities: Vec::new(),
|
||
commands: ["w", "q", "q!", "wq", "plugins"]
|
||
.into_iter()
|
||
.map(str::to_owned)
|
||
.collect(),
|
||
core: true,
|
||
},
|
||
true,
|
||
)
|
||
.map_err(io::Error::other)?;
|
||
Ok(editor)
|
||
}
|
||
|
||
pub fn lines(&self) -> &[String] {
|
||
&self.lines
|
||
}
|
||
pub fn cursor(&self) -> Cursor {
|
||
self.targets[self.primary].active
|
||
}
|
||
pub fn mode(&self) -> Mode {
|
||
self.mode
|
||
}
|
||
/// Read-only display state for the overlay; opening it does not change mode.
|
||
pub fn palette(&self) -> Option<&Palette> {
|
||
self.palette.as_ref()
|
||
}
|
||
/// 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.search_match
|
||
}
|
||
/// Ordered, end-exclusive Visual ranges in document order; empty in other modes.
|
||
pub fn selections(&self) -> Vec<Selection> {
|
||
if self.mode == Mode::Visual {
|
||
self.targets
|
||
.iter()
|
||
.map(|target| self.target_range(*target))
|
||
.collect()
|
||
} else {
|
||
Vec::new()
|
||
}
|
||
}
|
||
/// All active cursors in document order, including the primary cursor.
|
||
pub fn carets(&self) -> Vec<Cursor> {
|
||
self.targets.iter().map(|target| target.active).collect()
|
||
}
|
||
pub fn selection_count(&self) -> usize {
|
||
self.targets.len()
|
||
}
|
||
pub fn message(&self) -> &str {
|
||
&self.message
|
||
}
|
||
pub fn path(&self) -> Option<&Path> {
|
||
self.path.as_deref()
|
||
}
|
||
pub fn dirty(&self) -> bool {
|
||
self.lines != self.saved_lines
|
||
}
|
||
pub fn should_quit(&self) -> bool {
|
||
self.quit
|
||
}
|
||
|
||
pub fn context(&self) -> Context {
|
||
Context {
|
||
text: self.lines.join("\n"),
|
||
row: self.cursor().row + 1,
|
||
column: self.cursor().column + 1,
|
||
mode: self.mode.to_string(),
|
||
path: self
|
||
.path
|
||
.as_ref()
|
||
.map(|path| path.to_string_lossy().into_owned()),
|
||
}
|
||
}
|
||
|
||
pub fn set_message(&mut self, message: impl Into<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) {
|
||
if self.features.get(name).is_some_and(|feature| feature.core) {
|
||
self.set_message("Core features cannot be disabled");
|
||
return;
|
||
}
|
||
if let Some(feature) = self.features.remove(name) {
|
||
for bindings in self.bindings.values_mut() {
|
||
bindings.retain(|_, binding| {
|
||
!matches!(binding, Binding::Command(command) if feature.commands.contains(command))
|
||
});
|
||
}
|
||
}
|
||
self.refresh_palette();
|
||
self.set_message(format!("Plugin {name} disabled: {reason}"));
|
||
}
|
||
|
||
/// Overrides one mode's binding. `None` disables the key, including literal
|
||
/// typing in Insert/Command mode. Invalid overrides leave the binding intact.
|
||
pub fn bind(&mut self, mode: &str, key: &str, target: Option<String>) -> Result<(), String> {
|
||
let mode = match mode {
|
||
"normal" => Mode::Normal,
|
||
"insert" => Mode::Insert,
|
||
"command" => Mode::Command,
|
||
"search" => Mode::Search,
|
||
"visual" => Mode::Visual,
|
||
_ => return Err(format!("Unknown binding mode: {mode}")),
|
||
};
|
||
let key = parse_key(key)?;
|
||
let binding = match target {
|
||
None => Binding::Disabled,
|
||
Some(target) if target.starts_with("core.") => Binding::Action(
|
||
Action::named(mode, &target)
|
||
.ok_or_else(|| format!("Unknown action for {mode}: {target}"))?,
|
||
),
|
||
Some(command) => {
|
||
if mode != Mode::Normal {
|
||
return Err("Ex/plugin commands can only be bound in Normal mode".into());
|
||
}
|
||
if self.owner(&command).is_none() {
|
||
return Err(format!("Unknown command: {command}"));
|
||
}
|
||
Binding::Command(command)
|
||
}
|
||
};
|
||
self.bindings.entry(mode).or_default().insert(key, binding);
|
||
self.refresh_palette();
|
||
Ok(())
|
||
}
|
||
|
||
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.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();
|
||
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.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.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::Up | Input::Ctrl('k') if !palette.entries.is_empty() => {
|
||
palette.selected = if palette.selected == 0 {
|
||
palette.entries.len() - 1
|
||
} else {
|
||
palette.selected - 1
|
||
};
|
||
}
|
||
Input::Down | Input::Ctrl('n') if !palette.entries.is_empty() => {
|
||
palette.selected = (palette.selected + 1) % palette.entries.len();
|
||
}
|
||
Input::Enter => {
|
||
let entry = palette
|
||
.entries
|
||
.get(palette.selected)
|
||
.filter(|entry| entry.enabled)?;
|
||
if let Some(action) = Action::named(self.mode, &entry.name) {
|
||
self.palette = None;
|
||
return self.act(action);
|
||
}
|
||
self.command_line = format!("{} ", entry.name);
|
||
self.palette = None;
|
||
self.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
|
||
}
|
||
|
||
pub fn handle(&mut self, input: Input) -> Option<Invocation> {
|
||
if self.palette.is_some() {
|
||
let input = match input {
|
||
Input::Ctrl(ch) => control_key(ch),
|
||
input => input,
|
||
};
|
||
return self.handle_palette(input);
|
||
}
|
||
// Paste is literal input, never a configurable key or command.
|
||
if let Input::Paste(text) = input {
|
||
if self.mode == Mode::Insert {
|
||
self.paste(&text);
|
||
}
|
||
return None;
|
||
}
|
||
// Normalize terminal aliases for every frontend.
|
||
let input = match input {
|
||
Input::Ctrl(ch) => control_key(ch),
|
||
input => input,
|
||
};
|
||
match self
|
||
.bindings
|
||
.get(&self.mode)
|
||
.and_then(|keys| keys.get(&input))
|
||
{
|
||
Some(Binding::Action(action)) => return self.act(*action),
|
||
Some(Binding::Command(command)) => {
|
||
let command = command.clone();
|
||
return self.execute(&command);
|
||
}
|
||
Some(Binding::Disabled) => return None,
|
||
None => {}
|
||
}
|
||
match (self.mode, input) {
|
||
(Mode::Insert, Input::Char(ch)) if !ch.is_control() || ch == '\t' => {
|
||
let mut bytes = [0; 4];
|
||
self.insert_text(ch.encode_utf8(&mut bytes));
|
||
}
|
||
(Mode::Command | Mode::Search, Input::Char(ch))
|
||
if !ch.is_control() || (self.mode == Mode::Search && ch == '\t') =>
|
||
{
|
||
self.command_line.push(ch);
|
||
}
|
||
_ => {}
|
||
}
|
||
None
|
||
}
|
||
|
||
fn act(&mut self, action: Action) -> Option<Invocation> {
|
||
match action {
|
||
Action::Palette => {
|
||
self.palette = Some(Palette {
|
||
query: String::new(),
|
||
entries: Vec::new(),
|
||
selected: 0,
|
||
});
|
||
self.refresh_palette();
|
||
}
|
||
Action::Insert => self.begin_insert(),
|
||
Action::InsertFirstNonblank | Action::AppendLineEnd => {
|
||
if self.mode == Mode::Visual {
|
||
let before = self.snapshot();
|
||
self.split_rows(matches!(action, Action::AppendLineEnd), true);
|
||
self.begin_insert();
|
||
self.insert_start = Some(before);
|
||
} else {
|
||
self.begin_insert();
|
||
for target in &mut self.targets {
|
||
let line = &self.lines[target.active.row];
|
||
target.active.column = if matches!(action, Action::AppendLineEnd) {
|
||
grapheme_count(line)
|
||
} else {
|
||
first_nonblank(line)
|
||
};
|
||
}
|
||
self.normalize_targets();
|
||
}
|
||
}
|
||
Action::Append => {
|
||
self.begin_insert();
|
||
for target in &mut self.targets {
|
||
target.active.column = (target.active.column + 1)
|
||
.min(grapheme_count(&self.lines[target.active.row]));
|
||
}
|
||
self.normalize_targets();
|
||
}
|
||
Action::OpenBelow | Action::OpenAbove => {
|
||
self.begin_insert();
|
||
self.open_lines(matches!(action, Action::OpenBelow));
|
||
}
|
||
Action::Left
|
||
| Action::Right
|
||
| Action::Down
|
||
| Action::Up
|
||
| Action::LineStart
|
||
| Action::LineEnd
|
||
| Action::WordNext
|
||
| Action::WordPrevious
|
||
| Action::WordEnd
|
||
| Action::BigWordNext
|
||
| Action::BigWordPrevious
|
||
| Action::BigWordEnd => self.move_targets(action),
|
||
Action::JoinLines => self.join_lines(),
|
||
Action::Delete => {
|
||
let before = (self.mode != Mode::Insert).then(|| self.snapshot());
|
||
self.delete_forward();
|
||
if let Some(before) = before {
|
||
self.record(before);
|
||
}
|
||
}
|
||
Action::Undo => self.undo(),
|
||
Action::Redo => self.redo(),
|
||
Action::Command | Action::Search => {
|
||
self.mode = if matches!(action, Action::Search) {
|
||
Mode::Search
|
||
} else {
|
||
Mode::Command
|
||
};
|
||
self.command_line.clear();
|
||
}
|
||
Action::SearchNext => self.seek_search(true),
|
||
Action::SearchPrevious => self.seek_search(false),
|
||
Action::Select => {
|
||
if self.mode == Mode::Visual {
|
||
self.clear_selection();
|
||
} else {
|
||
for target in &mut self.targets {
|
||
target.anchor = target.active;
|
||
}
|
||
self.mode = Mode::Visual;
|
||
self.normalize_targets();
|
||
self.message.clear();
|
||
}
|
||
}
|
||
Action::SelectLines => self.split_rows(false, false),
|
||
Action::CloneAbove => self.clone_vertical(false),
|
||
Action::CloneBelow => self.clone_vertical(true),
|
||
Action::SelectNext | Action::SkipOccurrence | Action::SelectAll => {
|
||
self.select_occurrences(action);
|
||
}
|
||
Action::Copy | Action::Cut | Action::Change => self.edit_selection(action),
|
||
Action::PasteBefore => self.paste_clipboard(false),
|
||
Action::PasteAfter => self.paste_clipboard(true),
|
||
Action::Normal => {
|
||
if self.mode == Mode::Normal {
|
||
self.collapse_primary();
|
||
} else if self.mode == Mode::Insert {
|
||
self.finish_insert();
|
||
self.mode = Mode::Normal;
|
||
for target in &mut self.targets {
|
||
target.active.column = target.active.column.saturating_sub(1);
|
||
}
|
||
self.normalize_targets();
|
||
} else {
|
||
self.command_line.clear();
|
||
self.mode = Mode::Normal;
|
||
self.normalize_targets();
|
||
}
|
||
}
|
||
Action::Newline => self.insert_text("\n"),
|
||
Action::Backspace => {
|
||
if self.mode == Mode::Insert {
|
||
self.backspace();
|
||
} else if let Some((byte, _)) = self.command_line.grapheme_indices(true).next_back()
|
||
{
|
||
self.command_line.truncate(byte);
|
||
}
|
||
}
|
||
Action::Submit => {
|
||
let command = std::mem::take(&mut self.command_line);
|
||
let searching = self.mode == Mode::Search;
|
||
self.mode = Mode::Normal;
|
||
if searching {
|
||
if !command.is_empty() {
|
||
self.search_query = command;
|
||
}
|
||
self.seek_search(true);
|
||
} else {
|
||
return self.execute(&command);
|
||
}
|
||
}
|
||
}
|
||
None
|
||
}
|
||
|
||
fn seek_search(&mut self, forward: bool) {
|
||
self.search_match = None;
|
||
if self.search_query.is_empty() {
|
||
self.set_message("No previous search");
|
||
return;
|
||
}
|
||
let query = self.search_query.as_str();
|
||
// ponytail: O(buffer graphemes × query bytes) scan; index matches if large buffers need it.
|
||
let matches: Vec<_> = self
|
||
.lines
|
||
.iter()
|
||
.enumerate()
|
||
.flat_map(|(row, line)| {
|
||
line.grapheme_indices(true).filter_map(move |(byte, _)| {
|
||
(line[byte..].starts_with(query)
|
||
&& GraphemeCursor::new(byte + query.len(), line.len(), true)
|
||
.is_boundary(line, 0)
|
||
.expect("complete line supplies grapheme context"))
|
||
.then_some((row, byte))
|
||
})
|
||
})
|
||
.collect();
|
||
if matches.is_empty() {
|
||
self.set_message(format!("Not found: {}", self.search_query));
|
||
return;
|
||
}
|
||
for (index, target) in self.targets.iter_mut().enumerate() {
|
||
let origin = (
|
||
target.active.row,
|
||
grapheme_byte(&self.lines[target.active.row], target.active.column),
|
||
);
|
||
let found = if forward {
|
||
select_search_match(matches.iter().copied(), origin, true)
|
||
} else {
|
||
select_search_match(matches.iter().copied().rev(), origin, false)
|
||
}
|
||
.expect("nonempty matches");
|
||
target.active = Cursor {
|
||
row: found.0,
|
||
column: column_after_byte(&self.lines[found.0], found.1),
|
||
};
|
||
target.preferred_columns = None;
|
||
if index == self.primary {
|
||
self.search_match = Some(SearchMatch {
|
||
start: target.active,
|
||
end_column: target.active.column + grapheme_count(query),
|
||
});
|
||
}
|
||
}
|
||
self.normalize_targets();
|
||
self.message.clear();
|
||
}
|
||
|
||
pub fn execute(&mut self, command: &str) -> Option<Invocation> {
|
||
self.palette = None;
|
||
if self.mode == Mode::Insert {
|
||
self.finish_insert();
|
||
}
|
||
self.clear_selection();
|
||
self.mode = Mode::Normal;
|
||
self.normalize_targets();
|
||
let command = command.trim().strip_prefix(':').unwrap_or(command.trim());
|
||
let (name, args) = command
|
||
.split_once(char::is_whitespace)
|
||
.unwrap_or((command, ""));
|
||
let args = args.trim();
|
||
let Some(feature) = self.owner(name) else {
|
||
self.set_message(format!("Unknown command: {name}"));
|
||
return None;
|
||
};
|
||
if !feature.core {
|
||
return Some(Invocation {
|
||
plugin: feature.name.clone(),
|
||
command: name.into(),
|
||
args: args.into(),
|
||
});
|
||
}
|
||
match name {
|
||
"w" | "wq" => {
|
||
if self.save(if args.is_empty() {
|
||
None
|
||
} else {
|
||
Some(PathBuf::from(args))
|
||
}) && name == "wq"
|
||
{
|
||
self.quit = true;
|
||
}
|
||
}
|
||
"q" | "q!" if !args.is_empty() => self.set_message("Quit commands take no arguments"),
|
||
"q" if self.dirty() => self.set_message("Unsaved changes; use :w or :q!"),
|
||
"q" | "q!" => self.quit = true,
|
||
"plugins" if !args.is_empty() => {
|
||
self.set_message("The plugins command takes no arguments")
|
||
}
|
||
"plugins" => {
|
||
let mut features: Vec<_> = self.features.values().collect();
|
||
features.sort_unstable_by(|left, right| left.name.cmp(&right.name));
|
||
let mut message = format!("Capabilities: {}", self.capabilities.join(", "));
|
||
for feature in features {
|
||
message.push_str(" | ");
|
||
message.push_str(&feature.name);
|
||
if feature.core {
|
||
message.push_str(" [core]");
|
||
}
|
||
message.push_str(": ");
|
||
for (index, command) in feature.commands.iter().enumerate() {
|
||
if index > 0 {
|
||
message.push_str(", ");
|
||
}
|
||
message.push_str(command);
|
||
}
|
||
}
|
||
self.set_message(message);
|
||
}
|
||
_ => unreachable!("privately installed core commands"),
|
||
}
|
||
None
|
||
}
|
||
|
||
/// Applies one validated plugin response. Successful text effects collapse
|
||
/// to the primary caret: Insert runs once there, Replace replaces the buffer.
|
||
/// Rejected responses and message-only effects retain the complete set.
|
||
pub fn apply(&mut self, effects: Vec<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.mode;
|
||
let inserting = self.mode == Mode::Insert;
|
||
if inserting {
|
||
self.finish_insert();
|
||
}
|
||
let before = effects
|
||
.iter()
|
||
.any(|effect| {
|
||
matches!(effect, Effect::Insert(text) if !text.is_empty())
|
||
|| matches!(effect, Effect::Replace(_))
|
||
})
|
||
.then(|| self.snapshot());
|
||
if before.is_some() {
|
||
self.clear_selection();
|
||
self.collapse_primary();
|
||
// Plugin inserts use one primary insertion point, not the selection set.
|
||
self.mode = Mode::Insert;
|
||
}
|
||
for effect in effects {
|
||
match effect {
|
||
Effect::Insert(text) => self.insert_text(&normalize_newlines(&text)),
|
||
Effect::Replace(text) => {
|
||
self.lines = split_lines(&text);
|
||
self.search_match = None;
|
||
self.normalize_targets();
|
||
}
|
||
Effect::Message(text) => self.set_message(text),
|
||
}
|
||
}
|
||
if before.is_some() {
|
||
self.mode = if inserting {
|
||
Mode::Insert
|
||
} else {
|
||
Mode::Normal
|
||
};
|
||
}
|
||
self.normalize_targets();
|
||
if let Some(before) = before {
|
||
self.record(before);
|
||
}
|
||
if inserting {
|
||
self.insert_start = Some(self.snapshot());
|
||
}
|
||
if self.mode != previous_mode {
|
||
self.refresh_palette();
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn save(&mut self, path: Option<PathBuf>) -> bool {
|
||
let Some(path) = path.or_else(|| self.path.clone()) else {
|
||
self.set_message("No file name; use :w path");
|
||
return false;
|
||
};
|
||
match atomic_save(&path, &self.lines) {
|
||
Ok(()) => {
|
||
self.saved_lines.clone_from(&self.lines);
|
||
self.path = Some(path);
|
||
self.set_message("Written");
|
||
true
|
||
}
|
||
Err(error) => {
|
||
self.set_message(format!("Write failed: {error}"));
|
||
false
|
||
}
|
||
}
|
||
}
|
||
|
||
fn snapshot(&self) -> Snapshot {
|
||
Snapshot {
|
||
lines: self.lines.clone(),
|
||
targets: self.targets.clone(),
|
||
primary: self.primary,
|
||
}
|
||
}
|
||
|
||
fn record(&mut self, before: Snapshot) {
|
||
if before.lines != self.lines {
|
||
self.search_match = None;
|
||
self.undo.push(before);
|
||
self.redo.clear();
|
||
}
|
||
}
|
||
|
||
fn target_range(&self, target: Target) -> Selection {
|
||
let start = target.anchor.min(target.active);
|
||
let end = self.next_boundary(target.anchor.max(target.active));
|
||
Selection { start, end }
|
||
}
|
||
|
||
fn next_boundary(&self, mut cursor: Cursor) -> Cursor {
|
||
if cursor.column < grapheme_count(&self.lines[cursor.row]) {
|
||
cursor.column += 1;
|
||
} else if cursor.row + 1 < self.lines.len() {
|
||
cursor.row += 1;
|
||
cursor.column = 0;
|
||
}
|
||
cursor
|
||
}
|
||
|
||
fn previous_boundary(&self, mut cursor: Cursor) -> Cursor {
|
||
if cursor.column > 0 {
|
||
cursor.column -= 1;
|
||
} else if cursor.row > 0 {
|
||
cursor.row -= 1;
|
||
cursor.column = grapheme_count(&self.lines[cursor.row]);
|
||
}
|
||
cursor
|
||
}
|
||
|
||
fn range_target(&self, range: Selection, forward: bool) -> Target {
|
||
let last = if range.start == range.end {
|
||
range.start
|
||
} else {
|
||
self.previous_boundary(range.end)
|
||
};
|
||
Target {
|
||
anchor: if forward { range.start } else { last },
|
||
active: if forward { last } else { range.start },
|
||
preferred_columns: None,
|
||
}
|
||
}
|
||
|
||
fn max_column(&self, row: usize) -> usize {
|
||
let count = grapheme_count(&self.lines[row]);
|
||
if self.mode == Mode::Insert || (self.mode == Mode::Visual && row + 1 < self.lines.len()) {
|
||
count
|
||
} else {
|
||
count.saturating_sub(1)
|
||
}
|
||
}
|
||
|
||
fn normalize_targets(&mut self) {
|
||
for index in 0..self.targets.len() {
|
||
let mut target = self.targets[index];
|
||
target.active.row = target.active.row.min(self.lines.len() - 1);
|
||
target.active.column = target.active.column.min(self.max_column(target.active.row));
|
||
if self.mode == Mode::Visual {
|
||
target.anchor.row = target.anchor.row.min(self.lines.len() - 1);
|
||
target.anchor.column = target.anchor.column.min(self.max_column(target.anchor.row));
|
||
} else {
|
||
target.anchor = target.active;
|
||
}
|
||
self.targets[index] = target;
|
||
}
|
||
let mut ordered: Vec<_> = self
|
||
.targets
|
||
.iter()
|
||
.copied()
|
||
.enumerate()
|
||
.map(|(index, target)| {
|
||
let range = if self.mode == Mode::Visual {
|
||
self.target_range(target)
|
||
} else {
|
||
Selection {
|
||
start: target.active,
|
||
end: target.active,
|
||
}
|
||
};
|
||
(target, index == self.primary, range)
|
||
})
|
||
.collect();
|
||
ordered.sort_by_key(|(_, _, range)| (range.start, range.end));
|
||
let mut merged: Vec<(Target, bool, Selection)> = Vec::with_capacity(ordered.len());
|
||
for (target, primary, range) in ordered {
|
||
if let Some((previous, was_primary, previous_range)) = merged.last_mut()
|
||
&& ranges_intersect(*previous_range, range)
|
||
{
|
||
let representative = if primary { target } else { *previous };
|
||
previous_range.start = previous_range.start.min(range.start);
|
||
previous_range.end = previous_range.end.max(range.end);
|
||
*previous = if self.mode == Mode::Visual
|
||
&& self.target_range(representative) != *previous_range
|
||
{
|
||
self.range_target(
|
||
*previous_range,
|
||
representative.active >= representative.anchor,
|
||
)
|
||
} else {
|
||
representative
|
||
};
|
||
*was_primary |= primary;
|
||
continue;
|
||
}
|
||
merged.push((target, primary, range));
|
||
}
|
||
self.primary = merged
|
||
.iter()
|
||
.position(|(_, primary, _)| *primary)
|
||
.unwrap_or(0);
|
||
self.targets.clear();
|
||
self.targets
|
||
.extend(merged.into_iter().map(|(target, _, _)| target));
|
||
}
|
||
|
||
fn collapse_primary(&mut self) {
|
||
let target = self.targets[self.primary];
|
||
self.targets.clear();
|
||
self.targets.push(target);
|
||
self.primary = 0;
|
||
}
|
||
|
||
fn clear_selection(&mut self) {
|
||
if self.mode == Mode::Visual {
|
||
self.mode = Mode::Normal;
|
||
self.normalize_targets();
|
||
}
|
||
}
|
||
|
||
fn split_rows(&mut self, end: bool, insert: bool) {
|
||
let ranges = self.selections();
|
||
let primary_row = self.cursor().row;
|
||
let mut carets = Vec::new();
|
||
let mut primary = 0;
|
||
for (index, range) in ranges.into_iter().enumerate() {
|
||
let last = if range.end.column == 0 && range.end.row > range.start.row {
|
||
range.end.row - 1
|
||
} else {
|
||
range.end.row
|
||
};
|
||
for row in range.start.row..=last {
|
||
if index == self.primary && row == primary_row {
|
||
primary = carets.len();
|
||
}
|
||
let column = if end {
|
||
grapheme_count(&self.lines[row])
|
||
} else {
|
||
first_nonblank(&self.lines[row])
|
||
};
|
||
carets.push(Target::caret(Cursor { row, column }));
|
||
}
|
||
}
|
||
self.targets = carets;
|
||
self.primary = primary;
|
||
self.mode = if insert { Mode::Insert } else { Mode::Normal };
|
||
self.normalize_targets();
|
||
}
|
||
|
||
fn clone_vertical(&mut self, down: bool) {
|
||
let count = self.targets.len();
|
||
let primary = self.primary;
|
||
for index in 0..count {
|
||
let target = self.targets[index];
|
||
let shift = |row: usize| {
|
||
if down {
|
||
row.checked_add(1).filter(|row| *row < self.lines.len())
|
||
} else {
|
||
row.checked_sub(1)
|
||
}
|
||
};
|
||
let Some(anchor_row) = shift(target.anchor.row) else {
|
||
continue;
|
||
};
|
||
let Some(active_row) = shift(target.active.row) else {
|
||
continue;
|
||
};
|
||
let columns = target.preferred_columns.unwrap_or_else(|| {
|
||
(
|
||
display_column(&self.lines[target.anchor.row], target.anchor.column),
|
||
display_column(&self.lines[target.active.row], target.active.column),
|
||
)
|
||
});
|
||
self.targets[index].preferred_columns = Some(columns);
|
||
let cloned = Target {
|
||
anchor: Cursor {
|
||
row: anchor_row,
|
||
column: column_at_display(
|
||
&self.lines[anchor_row],
|
||
columns.0,
|
||
self.max_column(anchor_row),
|
||
),
|
||
},
|
||
active: Cursor {
|
||
row: active_row,
|
||
column: column_at_display(
|
||
&self.lines[active_row],
|
||
columns.1,
|
||
self.max_column(active_row),
|
||
),
|
||
},
|
||
preferred_columns: Some(columns),
|
||
};
|
||
if index == primary {
|
||
self.primary = self.targets.len();
|
||
}
|
||
self.targets.push(cloned);
|
||
}
|
||
self.normalize_targets();
|
||
}
|
||
|
||
fn select_occurrences(&mut self, action: Action) {
|
||
let text = self.lines.join("\n");
|
||
let offsets = line_offsets(&self.lines);
|
||
let ranges = self.selections();
|
||
let primary_range = ranges[self.primary];
|
||
let start = cursor_offset(&self.lines, &offsets, primary_range.start);
|
||
let end = cursor_offset(&self.lines, &offsets, primary_range.end);
|
||
if start == end {
|
||
self.set_message("Nothing selected");
|
||
return;
|
||
}
|
||
let phrase = &text[start..end];
|
||
let mut occupied: Vec<_> = ranges
|
||
.iter()
|
||
.map(|range| {
|
||
(
|
||
cursor_offset(&self.lines, &offsets, range.start),
|
||
cursor_offset(&self.lines, &offsets, range.end),
|
||
)
|
||
})
|
||
.collect();
|
||
// ponytail: literal scan is O(buffer graphemes × phrase bytes); index only for large buffers.
|
||
let candidates: Vec<_> = text
|
||
.grapheme_indices(true)
|
||
.filter_map(|(byte, _)| {
|
||
let end = byte + phrase.len();
|
||
(text[byte..].starts_with(phrase)
|
||
&& GraphemeCursor::new(end, text.len(), true)
|
||
.is_boundary(&text, 0)
|
||
.expect("complete buffer supplies grapheme context"))
|
||
.then_some((byte, end))
|
||
})
|
||
.collect();
|
||
if matches!(action, Action::SelectAll) {
|
||
for (start, end) in candidates {
|
||
if occupied
|
||
.iter()
|
||
.any(|(left, right)| start < *right && *left < end)
|
||
{
|
||
continue;
|
||
}
|
||
occupied.push((start, end));
|
||
self.targets.push(self.range_target(
|
||
Selection {
|
||
start: offset_cursor(&self.lines, &offsets, start, true),
|
||
end: offset_cursor(&self.lines, &offsets, end, true),
|
||
},
|
||
true,
|
||
));
|
||
}
|
||
} else {
|
||
let available = |(left, right): &(usize, usize)| {
|
||
!occupied
|
||
.iter()
|
||
.any(|(start, end)| *left < *end && *start < *right)
|
||
};
|
||
let next = candidates
|
||
.iter()
|
||
.filter(|range| available(range))
|
||
.find(|(left, _)| *left > start)
|
||
.or_else(|| candidates.iter().find(|range| available(range)));
|
||
let Some(&(start, end)) = next else {
|
||
self.set_message("No more occurrences");
|
||
return;
|
||
};
|
||
let target = self.range_target(
|
||
Selection {
|
||
start: offset_cursor(&self.lines, &offsets, start, true),
|
||
end: offset_cursor(&self.lines, &offsets, end, true),
|
||
},
|
||
true,
|
||
);
|
||
if matches!(action, Action::SkipOccurrence) {
|
||
self.targets[self.primary] = target;
|
||
} else {
|
||
self.primary = self.targets.len();
|
||
self.targets.push(target);
|
||
}
|
||
}
|
||
self.normalize_targets();
|
||
self.message.clear();
|
||
}
|
||
|
||
fn edit_selection(&mut self, action: Action) {
|
||
let ranges = self.selections();
|
||
let cut = matches!(action, Action::Cut | Action::Change);
|
||
let changing = matches!(action, Action::Change);
|
||
let before = cut.then(|| self.snapshot());
|
||
let text = self.lines.join("\n");
|
||
let offsets = line_offsets(&self.lines);
|
||
let fragments: Vec<_> = ranges
|
||
.iter()
|
||
.map(|range| {
|
||
let start = cursor_offset(&self.lines, &offsets, range.start);
|
||
let end = cursor_offset(&self.lines, &offsets, range.end);
|
||
text[start..end].to_owned()
|
||
})
|
||
.collect();
|
||
let nonempty = fragments.iter().any(|fragment| !fragment.is_empty());
|
||
if nonempty {
|
||
self.clipboard = fragments;
|
||
}
|
||
let edits = ranges
|
||
.iter()
|
||
.enumerate()
|
||
.map(|(target, range)| Edit {
|
||
target,
|
||
start: cursor_offset(&self.lines, &offsets, range.start),
|
||
end: cursor_offset(&self.lines, &offsets, range.end),
|
||
text: Cow::Borrowed(""),
|
||
caret: 0,
|
||
round_up: true,
|
||
})
|
||
.collect();
|
||
self.mode = Mode::Insert;
|
||
if cut {
|
||
self.apply_edits(edits);
|
||
} else {
|
||
for (target, range) in self.targets.iter_mut().zip(&ranges) {
|
||
target.active = range.start;
|
||
}
|
||
}
|
||
if changing {
|
||
self.insert_start = before;
|
||
self.message.clear();
|
||
} else {
|
||
self.mode = Mode::Normal;
|
||
self.normalize_targets();
|
||
if let Some(before) = before {
|
||
self.record(before);
|
||
}
|
||
self.set_message(if !nonempty {
|
||
"Nothing selected"
|
||
} else if cut {
|
||
"Cut selections"
|
||
} else {
|
||
"Copied selections"
|
||
});
|
||
}
|
||
}
|
||
|
||
fn paste_clipboard(&mut self, after: bool) {
|
||
if self.clipboard.is_empty() {
|
||
self.set_message("Clipboard is empty");
|
||
return;
|
||
}
|
||
if self.clipboard.len() != 1 && self.clipboard.len() != self.targets.len() {
|
||
self.set_message("Clipboard fragment count does not match caret count");
|
||
return;
|
||
}
|
||
let before = self.snapshot();
|
||
let clipboard = std::mem::take(&mut self.clipboard);
|
||
let offsets = line_offsets(&self.lines);
|
||
let edits = self
|
||
.targets
|
||
.iter()
|
||
.enumerate()
|
||
.map(|(target, item)| {
|
||
let mut cursor = item.active;
|
||
if after {
|
||
cursor.column =
|
||
(cursor.column + 1).min(grapheme_count(&self.lines[cursor.row]));
|
||
}
|
||
let start = cursor_offset(&self.lines, &offsets, cursor);
|
||
let text = &clipboard[if clipboard.len() == 1 { 0 } else { target }];
|
||
Edit {
|
||
target,
|
||
start,
|
||
end: start,
|
||
text: Cow::Borrowed(text),
|
||
caret: text.len(),
|
||
round_up: true,
|
||
}
|
||
})
|
||
.collect();
|
||
self.mode = Mode::Insert;
|
||
self.apply_edits(edits);
|
||
self.clipboard = clipboard;
|
||
for target in &mut self.targets {
|
||
target.active.column = target.active.column.saturating_sub(1);
|
||
}
|
||
self.mode = Mode::Normal;
|
||
self.normalize_targets();
|
||
self.record(before);
|
||
self.message.clear();
|
||
}
|
||
|
||
fn begin_insert(&mut self) {
|
||
self.insert_start = Some(self.snapshot());
|
||
self.mode = Mode::Insert;
|
||
}
|
||
|
||
fn finish_insert(&mut self) {
|
||
if let Some(before) = self.insert_start.take() {
|
||
self.record(before);
|
||
}
|
||
}
|
||
|
||
fn paste(&mut self, text: &str) {
|
||
let validation = if text.len() > MAX_EDIT_BYTES {
|
||
Err("Paste exceeds 4 MiB".into())
|
||
} else {
|
||
validate_text(text)
|
||
};
|
||
if let Err(error) = validation {
|
||
self.set_message(error);
|
||
return;
|
||
}
|
||
// Preserve the separate typing / paste / typing undo groups.
|
||
self.finish_insert();
|
||
let before = (!text.is_empty()).then(|| self.snapshot());
|
||
self.insert_text(&normalize_newlines(text));
|
||
if let Some(before) = before {
|
||
self.record(before);
|
||
}
|
||
self.insert_start = Some(self.snapshot());
|
||
}
|
||
|
||
fn restore(&mut self, snapshot: Snapshot) {
|
||
self.lines = snapshot.lines;
|
||
self.targets = snapshot.targets;
|
||
self.primary = snapshot.primary;
|
||
self.mode = Mode::Normal;
|
||
self.search_match = None;
|
||
self.normalize_targets();
|
||
}
|
||
|
||
fn undo(&mut self) {
|
||
if let Some(before) = self.undo.pop() {
|
||
self.redo.push(self.snapshot());
|
||
self.restore(before);
|
||
} else {
|
||
self.set_message("Nothing to undo");
|
||
}
|
||
}
|
||
|
||
fn redo(&mut self) {
|
||
if let Some(after) = self.redo.pop() {
|
||
self.undo.push(self.snapshot());
|
||
self.restore(after);
|
||
} else {
|
||
self.set_message("Nothing to redo");
|
||
}
|
||
}
|
||
|
||
fn move_targets(&mut self, action: Action) {
|
||
for index in 0..self.targets.len() {
|
||
let mut cursor = self.targets[index].active;
|
||
match action {
|
||
Action::Left => cursor.column = cursor.column.saturating_sub(1),
|
||
Action::Right => {
|
||
cursor.column = (cursor.column + 1).min(self.max_column(cursor.row))
|
||
}
|
||
Action::Down => {
|
||
cursor.row = (cursor.row + 1).min(self.lines.len() - 1);
|
||
cursor.column = cursor.column.min(self.max_column(cursor.row));
|
||
}
|
||
Action::Up => {
|
||
cursor.row = cursor.row.saturating_sub(1);
|
||
cursor.column = cursor.column.min(self.max_column(cursor.row));
|
||
}
|
||
Action::LineStart => cursor.column = 0,
|
||
Action::LineEnd => cursor.column = self.max_column(cursor.row),
|
||
_ => cursor = self.word_destination(cursor, action),
|
||
}
|
||
self.targets[index].active = cursor;
|
||
self.targets[index].preferred_columns = None;
|
||
}
|
||
self.normalize_targets();
|
||
}
|
||
fn word_destination(&self, cursor: Cursor, action: Action) -> Cursor {
|
||
let row = cursor.row;
|
||
let byte = grapheme_byte(&self.lines[row], cursor.column);
|
||
let big = matches!(
|
||
action,
|
||
Action::BigWordNext | Action::BigWordPrevious | Action::BigWordEnd
|
||
);
|
||
let destination = match action {
|
||
Action::WordPrevious | Action::BigWordPrevious => {
|
||
let mut tokens = word_tokens(&self.lines[..=row], big)
|
||
.rev()
|
||
.skip_while(|(r, b, _)| (*r, *b) >= (row, byte));
|
||
tokens
|
||
.find(|(_, _, kind)| *kind != WordKind::Whitespace)
|
||
.map(|first| {
|
||
if first.2 == WordKind::EmptyLine {
|
||
first
|
||
} else {
|
||
tokens
|
||
.take_while(|(_, _, kind)| *kind == first.2)
|
||
.last()
|
||
.unwrap_or(first)
|
||
}
|
||
})
|
||
.map(|(r, b, _)| (r, b))
|
||
.unwrap_or((0, 0))
|
||
}
|
||
_ => {
|
||
let mut tokens = word_tokens(&self.lines[row..], big)
|
||
.skip_while(|(r, b, _)| (*r, *b) < (0, byte));
|
||
let current = tokens
|
||
.next()
|
||
.expect("cursor has a grapheme or line-end token")
|
||
.2;
|
||
let found = match action {
|
||
Action::WordNext | Action::BigWordNext => tokens
|
||
.skip_while(|(_, _, kind)| {
|
||
*kind == current && current != WordKind::EmptyLine
|
||
})
|
||
.find(|(_, _, kind)| *kind != WordKind::Whitespace),
|
||
Action::WordEnd | Action::BigWordEnd => tokens
|
||
.find(|(_, _, kind)| {
|
||
!matches!(kind, WordKind::Whitespace | WordKind::EmptyLine)
|
||
})
|
||
.map(|first| {
|
||
tokens
|
||
.take_while(|(_, _, kind)| *kind == first.2)
|
||
.last()
|
||
.unwrap_or(first)
|
||
}),
|
||
_ => unreachable!("word motion actions only"),
|
||
};
|
||
found.map(|(r, b, _)| (row + r, b)).unwrap_or_else(|| {
|
||
let last = self.lines.len() - 1;
|
||
(last, self.lines[last].len())
|
||
})
|
||
}
|
||
};
|
||
let line = &self.lines[destination.0];
|
||
let column = column_after_byte(line, destination.1);
|
||
Cursor {
|
||
row: destination.0,
|
||
column: if destination.1 == line.len() && self.mode != Mode::Insert {
|
||
column.saturating_sub(1)
|
||
} else {
|
||
column
|
||
},
|
||
}
|
||
}
|
||
|
||
/// Every edit in a batch refers to this one pre-edit buffer. Duplicate row
|
||
/// operations share an edit; caret destinations are remapped only afterwards.
|
||
// ponytail: rebuilding text costs O(buffer + carets²); use per-line edits for large buffers.
|
||
fn apply_edits(&mut self, mut edits: Vec<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.lines.join("\n");
|
||
let offsets = line_offsets(&self.lines);
|
||
let removed: usize = unique
|
||
.iter()
|
||
.map(|index| edits[*index].end - edits[*index].start)
|
||
.sum();
|
||
let added: usize = unique.iter().map(|index| edits[*index].text.len()).sum();
|
||
let mut text = String::with_capacity(old.len() - removed + added);
|
||
let mut consumed = 0;
|
||
for index in &unique {
|
||
let edit = &edits[*index];
|
||
assert!(edit.start >= consumed, "batch edits must not overlap");
|
||
text.push_str(&old[consumed..edit.start]);
|
||
text.push_str(&edit.text);
|
||
consumed = edit.end;
|
||
}
|
||
text.push_str(&old[consumed..]);
|
||
let remap = |position: usize, after: bool| {
|
||
let mut removed = 0;
|
||
let mut added = 0;
|
||
for index in &unique {
|
||
let edit = &edits[*index];
|
||
if position < edit.start {
|
||
break;
|
||
}
|
||
if position == edit.start || position < edit.end {
|
||
return edit.start - removed + added + if after { edit.text.len() } else { 0 };
|
||
}
|
||
removed += edit.end - edit.start;
|
||
added += edit.text.len();
|
||
}
|
||
position - removed + added
|
||
};
|
||
let mut destinations: Vec<_> = self
|
||
.targets
|
||
.iter()
|
||
.map(|target| {
|
||
(
|
||
remap(cursor_offset(&self.lines, &offsets, target.active), true),
|
||
true,
|
||
)
|
||
})
|
||
.collect();
|
||
for edit in &edits {
|
||
destinations[edit.target] = (remap(edit.start, false) + edit.caret, edit.round_up);
|
||
}
|
||
if text != old {
|
||
self.search_match = None;
|
||
}
|
||
self.lines = text.split('\n').map(str::to_owned).collect();
|
||
let offsets = line_offsets(&self.lines);
|
||
for (target, (byte, round_up)) in self.targets.iter_mut().zip(destinations) {
|
||
target.active = offset_cursor(&self.lines, &offsets, byte, round_up);
|
||
target.anchor = target.active;
|
||
target.preferred_columns = None;
|
||
}
|
||
self.normalize_targets();
|
||
}
|
||
|
||
fn insert_text(&mut self, text: &str) {
|
||
if text.is_empty() {
|
||
return;
|
||
}
|
||
let offsets = line_offsets(&self.lines);
|
||
let edits = self
|
||
.targets
|
||
.iter()
|
||
.enumerate()
|
||
.map(|(target, item)| {
|
||
let start = cursor_offset(&self.lines, &offsets, item.active);
|
||
Edit {
|
||
target,
|
||
start,
|
||
end: start,
|
||
text: Cow::Borrowed(text),
|
||
caret: text.len(),
|
||
round_up: true,
|
||
}
|
||
})
|
||
.collect();
|
||
self.apply_edits(edits);
|
||
}
|
||
|
||
fn open_lines(&mut self, below: bool) {
|
||
let offsets = line_offsets(&self.lines);
|
||
let edits = self
|
||
.targets
|
||
.iter()
|
||
.enumerate()
|
||
.map(|(target, item)| {
|
||
let start = offsets[item.active.row]
|
||
+ if below {
|
||
self.lines[item.active.row].len()
|
||
} else {
|
||
0
|
||
};
|
||
Edit {
|
||
target,
|
||
start,
|
||
end: start,
|
||
text: Cow::Borrowed("\n"),
|
||
caret: usize::from(below),
|
||
round_up: true,
|
||
}
|
||
})
|
||
.collect();
|
||
self.apply_edits(edits);
|
||
}
|
||
|
||
fn delete_forward(&mut self) {
|
||
let offsets = line_offsets(&self.lines);
|
||
let edits = self
|
||
.targets
|
||
.iter()
|
||
.enumerate()
|
||
.filter_map(|(target, item)| {
|
||
let cursor = item.active;
|
||
let start = cursor_offset(&self.lines, &offsets, cursor);
|
||
let end = if cursor.column < grapheme_count(&self.lines[cursor.row]) {
|
||
cursor_offset(&self.lines, &offsets, self.next_boundary(cursor))
|
||
} else if self.mode == Mode::Insert && cursor.row + 1 < self.lines.len() {
|
||
start + 1
|
||
} else {
|
||
return None;
|
||
};
|
||
Some(Edit {
|
||
target,
|
||
start,
|
||
end,
|
||
text: Cow::Borrowed(""),
|
||
caret: 0,
|
||
round_up: true,
|
||
})
|
||
})
|
||
.collect();
|
||
self.apply_edits(edits);
|
||
}
|
||
|
||
fn join_lines(&mut self) {
|
||
let before = self.snapshot();
|
||
let offsets = line_offsets(&self.lines);
|
||
let edits = self
|
||
.targets
|
||
.iter()
|
||
.enumerate()
|
||
.filter_map(|(target, item)| {
|
||
let row = item.active.row;
|
||
let line = &self.lines[row];
|
||
let next = self.lines.get(row + 1)?;
|
||
let trimmed = next.trim_start_matches([' ', '\t']);
|
||
let separator = if !line.is_empty()
|
||
&& !trimmed.is_empty()
|
||
&& !line.ends_with([' ', '\t'])
|
||
&& !trimmed.starts_with(')')
|
||
{
|
||
if line.ends_with(['.', '?', '!']) {
|
||
" "
|
||
} else {
|
||
" "
|
||
}
|
||
} else {
|
||
""
|
||
};
|
||
let start = offsets[row] + line.len();
|
||
Some(Edit {
|
||
target,
|
||
start,
|
||
end: start + 1 + next.len() - trimmed.len(),
|
||
text: Cow::Borrowed(separator),
|
||
caret: 0,
|
||
round_up: false,
|
||
})
|
||
})
|
||
.collect();
|
||
self.apply_edits(edits);
|
||
self.record(before);
|
||
}
|
||
|
||
fn backspace(&mut self) {
|
||
let offsets = line_offsets(&self.lines);
|
||
let edits = self
|
||
.targets
|
||
.iter()
|
||
.enumerate()
|
||
.filter_map(|(target, item)| {
|
||
let cursor = item.active;
|
||
if cursor.column == 0 && cursor.row == 0 {
|
||
return None;
|
||
}
|
||
let start = cursor_offset(&self.lines, &offsets, self.previous_boundary(cursor));
|
||
let end = cursor_offset(&self.lines, &offsets, cursor);
|
||
Some(Edit {
|
||
target,
|
||
start,
|
||
end,
|
||
text: Cow::Borrowed(""),
|
||
caret: 0,
|
||
round_up: true,
|
||
})
|
||
})
|
||
.collect();
|
||
self.apply_edits(edits);
|
||
}
|
||
}
|
||
|
||
fn ranges_intersect(left: Selection, right: Selection) -> bool {
|
||
if left.start == left.end {
|
||
left.start == right.start || (right.start < left.start && left.start < right.end)
|
||
} else if right.start == right.end {
|
||
left.start <= right.start && right.start < left.end
|
||
} else {
|
||
left.start < right.end && right.start < left.end
|
||
}
|
||
}
|
||
|
||
fn first_nonblank(line: &str) -> usize {
|
||
line.graphemes(true)
|
||
.take_while(|grapheme| matches!(*grapheme, " " | "\t"))
|
||
.count()
|
||
}
|
||
|
||
fn display_column(line: &str, column: usize) -> usize {
|
||
line.graphemes(true)
|
||
.take(column)
|
||
.fold(0, |cells, grapheme| {
|
||
cells
|
||
+ if grapheme == "\t" {
|
||
4 - cells % 4
|
||
} else {
|
||
grapheme.width()
|
||
}
|
||
})
|
||
}
|
||
|
||
fn column_at_display(line: &str, wanted: usize, maximum: usize) -> usize {
|
||
let mut cells = 0;
|
||
let mut count = 0;
|
||
for (column, grapheme) in line.graphemes(true).enumerate() {
|
||
let width = if grapheme == "\t" {
|
||
4 - cells % 4
|
||
} else {
|
||
grapheme.width()
|
||
};
|
||
if cells + width > wanted {
|
||
return column.min(maximum);
|
||
}
|
||
cells += width;
|
||
count = column + 1;
|
||
}
|
||
count.min(maximum)
|
||
}
|
||
|
||
fn line_offsets(lines: &[String]) -> Vec<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 }
|
||
}
|
||
|
||
fn select_search_match(
|
||
matches: impl Iterator<Item = (usize, usize)>,
|
||
origin: (usize, usize),
|
||
forward: bool,
|
||
) -> Option<(usize, usize)> {
|
||
let mut wrapped = None;
|
||
for position in matches {
|
||
wrapped.get_or_insert(position);
|
||
if (forward && position > origin) || (!forward && position < origin) {
|
||
return Some(position);
|
||
}
|
||
}
|
||
wrapped
|
||
}
|
||
|
||
#[derive(Clone, Copy, Eq, PartialEq)]
|
||
enum WordKind {
|
||
Whitespace,
|
||
Keyword,
|
||
Punctuation,
|
||
EmptyLine,
|
||
}
|
||
|
||
fn word_tokens(
|
||
lines: &[String],
|
||
big: bool,
|
||
) -> impl DoubleEndedIterator<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_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 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 {
|
||
Editor::open(None, vec!["text".into()]).unwrap()
|
||
}
|
||
fn type_text(editor: &mut Editor, text: &str) {
|
||
for ch in text.chars() {
|
||
editor.handle(if ch == '\n' {
|
||
Input::Enter
|
||
} else {
|
||
Input::Char(ch)
|
||
});
|
||
}
|
||
}
|
||
fn submit_search(editor: &mut Editor, query: &str) {
|
||
editor.handle(Input::Char('/'));
|
||
type_text(editor, query);
|
||
editor.handle(Input::Enter);
|
||
}
|
||
fn plugin(name: &str, commands: &[&str]) -> Feature {
|
||
Feature {
|
||
name: name.into(),
|
||
required_capabilities: vec!["text".into()],
|
||
commands: commands.iter().map(|name| (*name).into()).collect(),
|
||
core: false,
|
||
}
|
||
}
|
||
|
||
fn set_carets(editor: &mut Editor, positions: &[(usize, usize)], primary: usize) {
|
||
editor.mode = Mode::Normal;
|
||
editor.targets = positions
|
||
.iter()
|
||
.map(|&(row, column)| Target::caret(Cursor { row, column }))
|
||
.collect();
|
||
editor.primary = primary;
|
||
editor.normalize_targets();
|
||
}
|
||
|
||
fn set_ranges(editor: &mut Editor, ranges: &[(Cursor, Cursor)], primary: usize) {
|
||
editor.mode = Mode::Visual;
|
||
editor.targets = ranges
|
||
.iter()
|
||
.map(|&(start, end)| editor.range_target(Selection { start, end }, true))
|
||
.collect();
|
||
editor.primary = primary;
|
||
editor.normalize_targets();
|
||
}
|
||
|
||
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.lines.clone();
|
||
let targets = editor.targets.clone();
|
||
let primary = editor.primary;
|
||
let selections = editor.selections();
|
||
let prompt = editor.command_line.clone();
|
||
let search = editor.search_query.clone();
|
||
let found = editor.search_match;
|
||
let message = editor.message.clone();
|
||
let undo: Vec<_> = editor.undo.iter().map(&snapshot).collect();
|
||
let redo: Vec<_> = editor.redo.iter().map(&snapshot).collect();
|
||
let insert = editor.insert_start.as_ref().map(snapshot);
|
||
|
||
editor.handle(Input::Ctrl('p'));
|
||
assert!(editor.palette().is_some());
|
||
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.lines, lines);
|
||
assert_eq!(editor.targets, targets);
|
||
assert_eq!(editor.primary, primary);
|
||
assert_eq!(editor.selections(), selections);
|
||
assert_eq!(editor.command_line, prompt);
|
||
assert_eq!(editor.search_query, search);
|
||
assert_eq!(editor.search_match, found);
|
||
assert_eq!(editor.message, message);
|
||
assert_eq!(editor.undo.iter().map(&snapshot).collect::<Vec<_>>(), undo);
|
||
assert_eq!(editor.redo.iter().map(&snapshot).collect::<Vec<_>>(), redo);
|
||
assert_eq!(editor.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('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.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());
|
||
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!["Backspace", "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.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.targets.clone();
|
||
editor.handle(Input::Ctrl('p'));
|
||
type_text(&mut editor, "core.change");
|
||
let undo = editor.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.targets, targets);
|
||
assert_eq!(editor.undo.len(), undo);
|
||
editor.apply(vec![Effect::Message("ok".into())]).unwrap();
|
||
assert_eq!(editor.palette().unwrap().query, "core.change");
|
||
assert_eq!(editor.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.mode = Mode::Visual;
|
||
editor.targets = vec![
|
||
editor.range_target(
|
||
Selection {
|
||
start: Cursor { row: 0, column: 0 },
|
||
end: Cursor { row: 0, column: 2 },
|
||
},
|
||
true,
|
||
),
|
||
editor.range_target(
|
||
Selection {
|
||
start: Cursor { row: 0, column: 1 },
|
||
end: Cursor { row: 0, column: 3 },
|
||
},
|
||
false,
|
||
),
|
||
editor.range_target(
|
||
Selection {
|
||
start: Cursor { row: 0, column: 3 },
|
||
end: Cursor { row: 0, column: 4 },
|
||
},
|
||
true,
|
||
),
|
||
];
|
||
editor.primary = 1;
|
||
editor.normalize_targets();
|
||
assert_eq!(editor.selection_count(), 2);
|
||
assert_eq!(editor.selections()[0].end.column, 3);
|
||
assert_eq!(editor.cursor().column, 0);
|
||
editor.handle(Input::Char('d'));
|
||
assert_eq!(editor.context().text, "");
|
||
assert_eq!(editor.clipboard, vec!["a\u{301}👩🚀界", "z"]);
|
||
assert_eq!(editor.selection_count(), 1);
|
||
editor.handle(Input::Char('u'));
|
||
assert_eq!(
|
||
editor.carets(),
|
||
vec![Cursor { row: 0, column: 0 }, Cursor { row: 0, column: 3 }]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn clipboard_pairs_in_document_order_broadcasts_one_and_refuses_mismatch_atomically() {
|
||
let mut editor = editor();
|
||
editor
|
||
.apply(vec![Effect::Replace("ab\nxyzq".into())])
|
||
.unwrap();
|
||
set_ranges(
|
||
&mut editor,
|
||
&[
|
||
(Cursor { row: 0, column: 0 }, Cursor { row: 0, column: 1 }),
|
||
(Cursor { row: 1, column: 0 }, Cursor { row: 1, column: 3 }),
|
||
],
|
||
1,
|
||
);
|
||
editor.handle(Input::Char('d'));
|
||
assert_eq!(editor.context().text, "b\nq");
|
||
editor.handle(Input::Char('P'));
|
||
assert_eq!(editor.context().text, "ab\nxyzq");
|
||
editor.handle(Input::Char('u'));
|
||
editor.handle(Input::Escape);
|
||
let before = editor.snapshot();
|
||
let undo = editor.undo.len();
|
||
let redo = editor.redo.len();
|
||
editor.handle(Input::Char('p'));
|
||
assert_eq!(editor.lines, before.lines);
|
||
assert_eq!(editor.targets, before.targets);
|
||
assert_eq!(editor.primary, before.primary);
|
||
assert_eq!((editor.undo.len(), editor.redo.len()), (undo, redo));
|
||
assert!(editor.message().contains("fragment count"));
|
||
editor.clipboard = vec!["!".into()];
|
||
set_carets(&mut editor, &[(0, 0), (1, 0)], 0);
|
||
editor.handle(Input::Char('P'));
|
||
assert_eq!(editor.context().text, "!b\n!q");
|
||
}
|
||
|
||
#[test]
|
||
fn literal_multiline_occurrences_skip_wrap_select_all_and_escape_in_two_stages() {
|
||
let mut editor = editor();
|
||
editor
|
||
.apply(vec![Effect::Replace("α\nβ α\nβ α\nβ".into())])
|
||
.unwrap();
|
||
set_ranges(
|
||
&mut editor,
|
||
&[(Cursor { row: 0, column: 0 }, Cursor { row: 1, column: 1 })],
|
||
0,
|
||
);
|
||
editor.handle(Input::Ctrl('n'));
|
||
assert_eq!(editor.selection_count(), 2);
|
||
assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 });
|
||
editor.handle(Input::Ctrl('s'));
|
||
assert_eq!(editor.selection_count(), 2);
|
||
assert_eq!(editor.cursor(), Cursor { row: 3, column: 0 });
|
||
editor.handle(Input::Ctrl('n'));
|
||
assert_eq!(editor.selection_count(), 3);
|
||
assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 });
|
||
editor.handle(Input::Ctrl('a'));
|
||
assert_eq!(editor.selection_count(), 3);
|
||
editor.handle(Input::Escape);
|
||
assert_eq!(editor.mode(), Mode::Normal);
|
||
assert_eq!(editor.selection_count(), 3);
|
||
let primary = editor.cursor();
|
||
editor.handle(Input::Escape);
|
||
assert_eq!(editor.carets(), vec![primary]);
|
||
}
|
||
|
||
#[test]
|
||
fn occurrences_reject_grapheme_intersections_and_gather_nonoverlapping_literals() {
|
||
let mut editor = editor();
|
||
editor
|
||
.apply(vec![Effect::Replace("aa aa aaa a\u{301}a".into())])
|
||
.unwrap();
|
||
editor.handle(Input::Char('v'));
|
||
editor.handle(Input::Right);
|
||
editor.handle(Input::Ctrl('a'));
|
||
assert_eq!(
|
||
editor
|
||
.selections()
|
||
.iter()
|
||
.map(|range| range.start.column)
|
||
.collect::<Vec<_>>(),
|
||
vec![0, 3, 6]
|
||
);
|
||
assert_eq!(editor.cursor().column, 1);
|
||
editor.handle(Input::Ctrl('n'));
|
||
assert_eq!(editor.selection_count(), 3);
|
||
}
|
||
|
||
#[test]
|
||
fn vertical_clones_keep_display_columns_through_tabs_wide_and_short_rows() {
|
||
let mut editor = editor();
|
||
editor
|
||
.apply(vec![Effect::Replace("a\t界z\nx\n123456z".into())])
|
||
.unwrap();
|
||
editor.handle(Input::End);
|
||
editor.handle(Input::Ctrl('e'));
|
||
assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 });
|
||
editor.handle(Input::Ctrl('e'));
|
||
assert_eq!(editor.selection_count(), 3);
|
||
assert_eq!(editor.cursor(), Cursor { row: 2, column: 6 });
|
||
editor.handle(Input::Char('i'));
|
||
type_text(&mut editor, "!");
|
||
editor.handle(Input::Escape);
|
||
assert_eq!(editor.context().text, "a\t界!z\n!x\n123456!z");
|
||
assert_eq!(editor.selection_count(), 3);
|
||
editor.handle(Input::Escape);
|
||
assert_eq!(editor.selection_count(), 1);
|
||
}
|
||
|
||
#[test]
|
||
fn visual_clones_preserve_directed_ranges_and_motion_targets_every_range() {
|
||
let mut editor = editor();
|
||
editor
|
||
.apply(vec![Effect::Replace("abc\nabcd\nabcde".into())])
|
||
.unwrap();
|
||
editor.handle(Input::Right);
|
||
editor.handle(Input::Char('v'));
|
||
editor.handle(Input::Right);
|
||
editor.handle(Input::Ctrl('e'));
|
||
assert_eq!(editor.selection_count(), 2);
|
||
editor.handle(Input::Ctrl('e'));
|
||
assert_eq!(editor.selection_count(), 3);
|
||
editor.handle(Input::Left);
|
||
assert!(
|
||
editor
|
||
.selections()
|
||
.iter()
|
||
.all(|range| range.start.column == 1 && range.end.column == 2)
|
||
);
|
||
editor.handle(Input::Char('c'));
|
||
type_text(&mut editor, "!");
|
||
editor.handle(Input::Escape);
|
||
assert_eq!(editor.context().text, "a!c\na!cd\na!cde");
|
||
}
|
||
|
||
#[test]
|
||
fn batch_open_deduplicates_rows_and_join_uses_original_neighbor_rows() {
|
||
for (key, expected) in [('o', "abc\n\nxyz\n"), ('O', "\nabc\n\nxyz")] {
|
||
let mut editor = editor();
|
||
editor
|
||
.apply(vec![Effect::Replace("abc\nxyz".into())])
|
||
.unwrap();
|
||
set_carets(&mut editor, &[(0, 0), (0, 2), (1, 1)], 2);
|
||
editor.handle(Input::Char(key));
|
||
editor.handle(Input::Escape);
|
||
assert_eq!(editor.context().text, expected);
|
||
assert_eq!(editor.selection_count(), 2);
|
||
editor.handle(Input::Char('u'));
|
||
assert_eq!(editor.context().text, "abc\nxyz");
|
||
assert_eq!(editor.selection_count(), 3);
|
||
}
|
||
let mut editor = editor();
|
||
editor
|
||
.apply(vec![Effect::Replace("a\n b\n c".into())])
|
||
.unwrap();
|
||
set_carets(&mut editor, &[(0, 0), (1, 1)], 1);
|
||
editor.handle(Input::Char('J'));
|
||
assert_eq!(editor.context().text, "a b c");
|
||
editor.handle(Input::Char('u'));
|
||
assert_eq!(editor.context().text, "a\n b\n c");
|
||
assert_eq!(editor.selection_count(), 2);
|
||
}
|
||
|
||
#[test]
|
||
fn plugin_text_effects_and_invocation_are_single_primary_but_messages_are_not() {
|
||
let mut editor = editor();
|
||
editor
|
||
.apply(vec![Effect::Replace("aa\nbb\ncc".into())])
|
||
.unwrap();
|
||
editor.register(plugin("sample", &["sample"])).unwrap();
|
||
set_carets(&mut editor, &[(0, 0), (1, 0), (2, 0)], 1);
|
||
assert_eq!(editor.execute("sample").unwrap().command, "sample");
|
||
assert_eq!(editor.selection_count(), 3);
|
||
editor.handle(Input::Char('v'));
|
||
let ranges = editor.selections();
|
||
assert!(editor.apply(vec![Effect::Insert("\0".into())]).is_err());
|
||
editor.apply(vec![Effect::Message("ok".into())]).unwrap();
|
||
assert_eq!(editor.selections(), ranges);
|
||
editor.apply(vec![Effect::Insert("!".into())]).unwrap();
|
||
assert_eq!(editor.context().text, "aa\n!bb\ncc");
|
||
assert_eq!(editor.selection_count(), 1);
|
||
editor.handle(Input::Char('u'));
|
||
assert_eq!(editor.context().text, "aa\nbb\ncc");
|
||
assert_eq!(editor.selection_count(), 3);
|
||
assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 });
|
||
assert_eq!(editor.mode(), Mode::Normal);
|
||
}
|
||
|
||
#[test]
|
||
fn literal_paste_broadcasts_and_retains_separate_typing_history_for_all_carets() {
|
||
let mut editor = editor();
|
||
editor.apply(vec![Effect::Replace("a\nb".into())]).unwrap();
|
||
editor.handle(Input::Ctrl('e'));
|
||
editor.handle(Input::Char('i'));
|
||
type_text(&mut editor, "x");
|
||
editor.handle(Input::Paste("界e\u{301}\r\n".into()));
|
||
type_text(&mut editor, "z");
|
||
editor.handle(Input::Escape);
|
||
assert_eq!(editor.context().text, "x界e\u{301}\nza\nx界e\u{301}\nzb");
|
||
for expected in ["x界e\u{301}\na\nx界e\u{301}\nb", "xa\nxb", "a\nb"] {
|
||
editor.handle(Input::Char('u'));
|
||
assert_eq!(editor.context().text, expected);
|
||
assert_eq!(editor.selection_count(), 2);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn caret_motions_and_search_apply_to_all_targets_and_collapse_duplicates() {
|
||
let mut editor = editor();
|
||
editor
|
||
.apply(vec![Effect::Replace("cat cat\ncat cat".into())])
|
||
.unwrap();
|
||
set_carets(&mut editor, &[(0, 0), (1, 0), (1, 0)], 2);
|
||
assert_eq!(editor.selection_count(), 2);
|
||
assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 });
|
||
submit_search(&mut editor, "cat");
|
||
assert_eq!(
|
||
editor.carets(),
|
||
vec![Cursor { row: 0, column: 4 }, Cursor { row: 1, column: 4 }]
|
||
);
|
||
editor.handle(Input::Char('b'));
|
||
assert!(editor.carets().iter().all(|cursor| cursor.column == 0));
|
||
editor.handle(Input::Up);
|
||
assert_eq!(editor.carets(), vec![Cursor::default()]);
|
||
}
|
||
|
||
#[test]
|
||
fn word_motions_distinguish_punctuation_cross_empty_lines_and_preserve_history() {
|
||
let mut editor = editor();
|
||
let text = "foo_bar...baz qux\n\n \n界e\u{301}_2 👩🚀!! tail";
|
||
editor.apply(vec![Effect::Replace(text.into())]).unwrap();
|
||
for (key, row, column) in [
|
||
('w', 0, 7),
|
||
('w', 0, 10),
|
||
('w', 0, 15),
|
||
('w', 1, 0),
|
||
('w', 3, 0),
|
||
('w', 3, 5),
|
||
('w', 3, 9),
|
||
('w', 3, 12),
|
||
('w', 3, 12),
|
||
('b', 3, 9),
|
||
('b', 3, 5),
|
||
('b', 3, 0),
|
||
('b', 1, 0),
|
||
('b', 0, 15),
|
||
('b', 0, 10),
|
||
('b', 0, 7),
|
||
('b', 0, 0),
|
||
('b', 0, 0),
|
||
('e', 0, 6),
|
||
('e', 0, 9),
|
||
('e', 0, 12),
|
||
('e', 0, 17),
|
||
('e', 3, 3),
|
||
('e', 3, 7),
|
||
('e', 3, 12),
|
||
('e', 3, 12),
|
||
] {
|
||
editor.handle(Input::Char(key));
|
||
assert_eq!(editor.cursor(), Cursor { row, column }, "{key}");
|
||
}
|
||
assert_eq!(editor.context().text, text);
|
||
editor.handle(Input::Char('u'));
|
||
assert_eq!(editor.context().text, "");
|
||
editor.handle(Input::Char('U'));
|
||
assert_eq!(editor.context().text, text);
|
||
}
|
||
|
||
#[test]
|
||
fn big_word_motions_use_only_whitespace_and_handle_buffer_edges() {
|
||
let mut editor = editor();
|
||
editor
|
||
.apply(vec![Effect::Replace(
|
||
"foo_bar...baz qux\n\n \n界e\u{301}_2 👩🚀!! tail".into(),
|
||
)])
|
||
.unwrap();
|
||
for (key, row, column) in [
|
||
('W', 0, 15),
|
||
('W', 1, 0),
|
||
('W', 3, 0),
|
||
('W', 3, 5),
|
||
('W', 3, 9),
|
||
('W', 3, 12),
|
||
('B', 3, 9),
|
||
('B', 3, 5),
|
||
('B', 3, 0),
|
||
('B', 1, 0),
|
||
('B', 0, 15),
|
||
('B', 0, 0),
|
||
('B', 0, 0),
|
||
('E', 0, 12),
|
||
('E', 0, 17),
|
||
('E', 3, 3),
|
||
('E', 3, 7),
|
||
('E', 3, 12),
|
||
('E', 3, 12),
|
||
] {
|
||
editor.handle(Input::Char(key));
|
||
assert_eq!(editor.cursor(), Cursor { row, column }, "{key}");
|
||
}
|
||
for (text, next, end) in [("", 0, 0), (" ", 2, 2), ("word ", 6, 3)] {
|
||
editor.apply(vec![Effect::Replace(text.into())]).unwrap();
|
||
editor.handle(Input::Home);
|
||
editor.handle(Input::Char('e'));
|
||
assert_eq!(
|
||
editor.cursor(),
|
||
Cursor {
|
||
row: 0,
|
||
column: end
|
||
}
|
||
);
|
||
editor.handle(Input::Char('w'));
|
||
assert_eq!(
|
||
editor.cursor(),
|
||
Cursor {
|
||
row: 0,
|
||
column: next
|
||
}
|
||
);
|
||
editor.handle(Input::Char('b'));
|
||
assert_eq!(editor.cursor(), Cursor { row: 0, column: 0 });
|
||
}
|
||
editor
|
||
.apply(vec![Effect::Replace("one\u{2003}two\u{a0}three\n".into())])
|
||
.unwrap();
|
||
editor.handle(Input::Home);
|
||
editor.handle(Input::Char('w'));
|
||
assert_eq!(editor.cursor(), Cursor { row: 0, column: 4 });
|
||
editor.handle(Input::Char('w'));
|
||
assert_eq!(editor.cursor(), Cursor { row: 0, column: 8 });
|
||
editor.handle(Input::Char('w'));
|
||
assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 });
|
||
editor.handle(Input::Char('e'));
|
||
assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 });
|
||
editor.handle(Input::Char('b'));
|
||
assert_eq!(editor.cursor(), Cursor { row: 0, column: 8 });
|
||
}
|
||
|
||
#[test]
|
||
fn search_wraps_both_ways_and_cancel_failure_and_navigation_preserve_edits() {
|
||
let mut editor = editor();
|
||
let text = "zero cat\ncat cat\nCAT\n";
|
||
editor.apply(vec![Effect::Replace(text.into())]).unwrap();
|
||
submit_search(&mut editor, "cat");
|
||
assert_eq!(editor.cursor(), Cursor { row: 0, column: 5 });
|
||
assert_eq!(editor.search_match().unwrap().end_column, 8);
|
||
editor.handle(Input::Char('n'));
|
||
assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 });
|
||
editor.handle(Input::Char('n'));
|
||
assert_eq!(editor.cursor(), Cursor { row: 1, column: 4 });
|
||
editor.handle(Input::Char('n'));
|
||
assert_eq!(editor.cursor(), Cursor { row: 0, column: 5 });
|
||
editor.handle(Input::Char('N'));
|
||
assert_eq!(editor.cursor(), Cursor { row: 1, column: 4 });
|
||
editor.handle(Input::Char('N'));
|
||
assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 });
|
||
|
||
let cursor = editor.cursor();
|
||
let active = editor.search_match();
|
||
editor.handle(Input::Char('/'));
|
||
type_text(&mut editor, "other界e\u{301}");
|
||
editor.handle(Input::Backspace);
|
||
assert_eq!(editor.command_line(), "other界");
|
||
editor.handle(Input::Paste(":q!\n".into()));
|
||
assert_eq!(editor.command_line(), "other界");
|
||
editor.handle(Input::Escape);
|
||
assert_eq!(editor.mode(), Mode::Normal);
|
||
assert_eq!(editor.cursor(), cursor);
|
||
assert_eq!(editor.search_match(), active);
|
||
assert!(editor.command_line().is_empty());
|
||
submit_search(&mut editor, "");
|
||
assert_eq!(editor.cursor(), Cursor { row: 1, column: 4 });
|
||
|
||
let cursor = editor.cursor();
|
||
submit_search(&mut editor, "missing");
|
||
assert_eq!(editor.mode(), Mode::Normal);
|
||
assert_eq!(editor.cursor(), cursor);
|
||
assert_eq!(editor.search_match(), None);
|
||
editor.handle(Input::Char('N'));
|
||
assert_eq!(editor.cursor(), cursor);
|
||
assert!(!editor.should_quit());
|
||
assert_eq!(editor.context().text, text);
|
||
editor.handle(Input::Char('u'));
|
||
assert_eq!(editor.context().text, "");
|
||
editor.handle(Input::Char('U'));
|
||
assert_eq!(editor.context().text, text);
|
||
}
|
||
|
||
#[test]
|
||
fn search_is_literal_overlapping_and_grapheme_safe_and_rechecks_after_edits() {
|
||
let mut editor = editor();
|
||
editor
|
||
.apply(vec![Effect::Replace("e\u{301} e 👩🚀 👩 aa... aaa".into())])
|
||
.unwrap();
|
||
submit_search(&mut editor, "e");
|
||
assert_eq!(editor.cursor().column, 2);
|
||
submit_search(&mut editor, "\u{301}");
|
||
assert_eq!(editor.cursor().column, 2);
|
||
assert_eq!(editor.search_match(), None);
|
||
submit_search(&mut editor, "👩");
|
||
assert_eq!(editor.cursor().column, 6);
|
||
submit_search(&mut editor, "👩🚀");
|
||
assert_eq!(editor.cursor().column, 4);
|
||
assert_eq!(editor.search_match().unwrap().end_column, 5);
|
||
submit_search(&mut editor, ".");
|
||
assert_eq!(editor.cursor().column, 10);
|
||
editor.handle(Input::Char('n'));
|
||
assert_eq!(editor.cursor().column, 11);
|
||
submit_search(&mut editor, "aa");
|
||
assert_eq!(editor.cursor().column, 14);
|
||
editor.handle(Input::Char('n'));
|
||
assert_eq!(editor.cursor().column, 15);
|
||
editor.handle(Input::Char('n'));
|
||
assert_eq!(editor.cursor().column, 8);
|
||
submit_search(&mut editor, "e\u{301}");
|
||
assert_eq!(editor.cursor().column, 0);
|
||
assert_eq!(editor.search_match().unwrap().end_column, 1);
|
||
|
||
let active = editor.search_match();
|
||
assert!(editor.apply(vec![Effect::Replace("\0".into())]).is_err());
|
||
assert_eq!(editor.search_match(), active);
|
||
editor.handle(Input::Char('x'));
|
||
assert_eq!(editor.search_match(), None);
|
||
editor.handle(Input::Char('n'));
|
||
assert_eq!(editor.search_match(), None);
|
||
editor.handle(Input::Char('u'));
|
||
assert_eq!(editor.search_match(), None);
|
||
editor.handle(Input::Char('n'));
|
||
assert_eq!(editor.cursor().column, 0);
|
||
assert_eq!(editor.search_match().unwrap().end_column, 1);
|
||
editor
|
||
.apply(vec![Effect::Replace("e\u{301}".into())])
|
||
.unwrap();
|
||
assert_eq!(editor.search_match(), None);
|
||
editor.handle(Input::Char('n'));
|
||
assert_eq!(editor.cursor().column, 0);
|
||
assert_eq!(editor.search_match().unwrap().end_column, 1);
|
||
}
|
||
|
||
#[test]
|
||
fn join_lines_matches_vim_spacing_and_grapheme_cursor_positions() {
|
||
for (original, expected, column) in [
|
||
("alpha\n \tbeta\ntail", "alpha beta\ntail", 5),
|
||
("alpha \n\tbeta", "alpha beta", 7),
|
||
("alpha\t\n beta", "alpha\tbeta", 6),
|
||
("\n beta", "beta", 0),
|
||
("alpha\n \t", "alpha", 4),
|
||
("\n", "", 0),
|
||
("alpha\n )tail", "alpha)tail", 5),
|
||
("Sentence.\n next", "Sentence. next", 9),
|
||
("界e\u{301}\n next", "界e\u{301} next", 2),
|
||
("x \n\u{301}y", "x \u{301}y", 1),
|
||
] {
|
||
let mut editor = editor();
|
||
editor
|
||
.apply(vec![Effect::Replace(original.into())])
|
||
.unwrap();
|
||
editor.handle(Input::Char('J'));
|
||
assert_eq!(editor.context().text, expected);
|
||
assert_eq!(editor.cursor(), Cursor { row: 0, column });
|
||
assert_eq!(editor.mode(), Mode::Normal);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn join_is_one_undo_action_on_the_current_row_and_restores_cursor() {
|
||
let mut editor = editor();
|
||
let original = "above\nalpha\n beta\nbelow";
|
||
editor
|
||
.apply(vec![Effect::Replace(original.into())])
|
||
.unwrap();
|
||
editor.handle(Input::Down);
|
||
editor.handle(Input::Right);
|
||
let before = editor.cursor();
|
||
editor.handle(Input::Char('J'));
|
||
assert_eq!(editor.context().text, "above\nalpha beta\nbelow");
|
||
assert_eq!(editor.cursor(), Cursor { row: 1, column: 5 });
|
||
editor.handle(Input::Char('u'));
|
||
assert_eq!(editor.context().text, original);
|
||
assert_eq!(editor.cursor(), before);
|
||
editor.handle(Input::Char('U'));
|
||
assert_eq!(editor.context().text, "above\nalpha beta\nbelow");
|
||
editor.handle(Input::Char('x'));
|
||
assert_eq!(editor.context().text, "above\nalphabeta\nbelow");
|
||
}
|
||
|
||
#[test]
|
||
fn join_at_eof_preserves_history_and_insert_mode_j_is_literal() {
|
||
let mut editor = editor();
|
||
editor
|
||
.apply(vec![Effect::Replace("single".into())])
|
||
.unwrap();
|
||
editor.handle(Input::Char('u'));
|
||
editor.handle(Input::Char('J'));
|
||
assert_eq!(editor.context().text, "");
|
||
editor.handle(Input::Char('U'));
|
||
assert_eq!(editor.context().text, "single");
|
||
editor.handle(Input::Char('J'));
|
||
assert_eq!(editor.context().text, "single");
|
||
editor.handle(Input::Char('u'));
|
||
assert_eq!(editor.context().text, "");
|
||
editor.handle(Input::Char('U'));
|
||
editor.handle(Input::Char('i'));
|
||
editor.handle(Input::Char('J'));
|
||
editor.handle(Input::Escape);
|
||
assert_eq!(editor.context().text, "Jsingle");
|
||
}
|
||
|
||
#[test]
|
||
fn uppercase_u_redoes_and_control_r_no_longer_does() {
|
||
let mut editor = editor();
|
||
editor.handle(Input::Char('i'));
|
||
type_text(&mut editor, "changed");
|
||
editor.handle(Input::Escape);
|
||
editor.handle(Input::Char('u'));
|
||
assert_eq!(editor.context().text, "");
|
||
editor.handle(Input::Ctrl('r'));
|
||
assert_eq!(editor.context().text, "");
|
||
editor.handle(Input::Char('U'));
|
||
assert_eq!(editor.context().text, "changed");
|
||
}
|
||
|
||
#[test]
|
||
fn uppercase_insert_keys_target_line_positions_and_preserve_undo_cursor() {
|
||
for (original, key, expected) in [
|
||
(" \t界e\u{301} tail", 'I', " \tX界e\u{301} tail"),
|
||
(" \t ", 'I', " \t X"),
|
||
("", 'I', "X"),
|
||
(" \t界e\u{301} tail", 'A', " \t界e\u{301} tailX"),
|
||
("", 'A', "X"),
|
||
] {
|
||
let mut editor = editor();
|
||
editor
|
||
.apply(vec![Effect::Replace(original.into())])
|
||
.unwrap();
|
||
editor.handle(if key == 'I' { Input::End } else { Input::Right });
|
||
let before = editor.cursor();
|
||
editor.handle(Input::Char(key));
|
||
assert_eq!(editor.mode(), Mode::Insert);
|
||
type_text(&mut editor, "X");
|
||
editor.handle(Input::Escape);
|
||
assert_eq!(editor.context().text, expected);
|
||
editor.handle(Input::Char('u'));
|
||
assert_eq!(editor.context().text, original);
|
||
assert_eq!(editor.cursor(), before);
|
||
editor.handle(Input::Char('U'));
|
||
assert_eq!(editor.context().text, expected);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn paste_is_ignored_in_normal_and_command_modes_without_dispatching() {
|
||
let mut editor = editor();
|
||
assert_eq!(editor.handle(Input::Paste(":q!\r\n".into())), None);
|
||
assert_eq!(editor.mode(), Mode::Normal);
|
||
assert_eq!(editor.context().text, "");
|
||
assert!(!editor.dirty());
|
||
assert!(!editor.should_quit());
|
||
|
||
editor.handle(Input::Char(':'));
|
||
type_text(&mut editor, "q");
|
||
assert_eq!(editor.handle(Input::Paste("\rq!\n\0".into())), None);
|
||
assert_eq!(editor.mode(), Mode::Command);
|
||
assert_eq!(editor.command_line(), "q");
|
||
assert_eq!(editor.context().text, "");
|
||
assert!(!editor.should_quit());
|
||
editor.handle(Input::Enter);
|
||
assert!(editor.should_quit());
|
||
}
|
||
|
||
#[test]
|
||
fn pasted_unicode_and_newlines_are_literal_and_a_separate_undo_action() {
|
||
let mut editor = editor();
|
||
editor.handle(Input::Char('i'));
|
||
type_text(&mut editor, "a");
|
||
assert_eq!(
|
||
editor.handle(Input::Paste("界e\u{301}\t\r\n:q!\r".into())),
|
||
None
|
||
);
|
||
assert_eq!(editor.mode(), Mode::Insert);
|
||
assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 });
|
||
type_text(&mut editor, "z");
|
||
editor.handle(Input::Escape);
|
||
assert_eq!(editor.context().text, "a界e\u{301}\t\n:q!\nz");
|
||
assert!(!editor.should_quit());
|
||
|
||
for expected in ["a界e\u{301}\t\n:q!\n", "a", ""] {
|
||
editor.handle(Input::Char('u'));
|
||
assert_eq!(editor.context().text, expected);
|
||
}
|
||
for expected in ["a", "a界e\u{301}\t\n:q!\n", "a界e\u{301}\t\n:q!\nz"] {
|
||
editor.handle(Input::Char('U'));
|
||
assert_eq!(editor.context().text, expected);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn rejected_paste_preserves_text_cursor_mode_and_pending_insert_history() {
|
||
for text in [
|
||
"valid prefix\ninvalid\0".to_owned(),
|
||
"\u{1b}[31m".to_owned(),
|
||
"x".repeat(MAX_EDIT_BYTES + 1),
|
||
] {
|
||
let mut editor = editor();
|
||
editor.handle(Input::Char('i'));
|
||
type_text(&mut editor, "before");
|
||
let cursor = editor.cursor();
|
||
editor.handle(Input::Paste(text));
|
||
assert_eq!(editor.context().text, "before");
|
||
assert_eq!(editor.cursor(), cursor);
|
||
assert_eq!(editor.mode(), Mode::Insert);
|
||
assert!(
|
||
editor.message().contains("Unsupported control character")
|
||
|| editor.message().contains("Paste exceeds 4 MiB")
|
||
);
|
||
type_text(&mut editor, "after");
|
||
editor.handle(Input::Escape);
|
||
editor.handle(Input::Char('u'));
|
||
assert_eq!(editor.context().text, "");
|
||
editor.handle(Input::Char('U'));
|
||
assert_eq!(editor.context().text, "beforeafter");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn insert_sessions_and_open_lines_are_single_undo_actions() {
|
||
let mut editor = editor();
|
||
editor.handle(Input::Char('i'));
|
||
type_text(&mut editor, "hello\n世界");
|
||
editor.handle(Input::Escape);
|
||
assert_eq!(editor.context().text, "hello\n世界");
|
||
assert!(editor.dirty());
|
||
editor.handle(Input::Char('u'));
|
||
assert_eq!(editor.context().text, "");
|
||
assert!(!editor.dirty());
|
||
editor.handle(Input::Char('U'));
|
||
assert_eq!(editor.context().text, "hello\n世界");
|
||
editor.handle(Input::Char('O'));
|
||
type_text(&mut editor, "above");
|
||
editor.handle(Input::Escape);
|
||
assert_eq!(editor.context().text, "hello\nabove\n世界");
|
||
editor.handle(Input::Char('u'));
|
||
assert_eq!(editor.context().text, "hello\n世界");
|
||
editor.handle(Input::Char('o'));
|
||
type_text(&mut editor, "below");
|
||
editor.handle(Input::Escape);
|
||
assert_eq!(editor.context().text, "hello\n世界\nbelow");
|
||
editor.handle(Input::Char('u'));
|
||
assert_eq!(editor.context().text, "hello\n世界");
|
||
}
|
||
|
||
#[test]
|
||
fn grapheme_navigation_and_deletion_do_not_split_unicode() {
|
||
let mut editor = editor();
|
||
editor
|
||
.apply(vec![Effect::Replace("a\u{301}👩💻界".into())])
|
||
.unwrap();
|
||
editor.handle(Input::Char('l'));
|
||
assert_eq!(editor.cursor().column, 1);
|
||
editor.handle(Input::Char('x'));
|
||
assert_eq!(editor.context().text, "a\u{301}界");
|
||
editor.handle(Input::Char('0'));
|
||
editor.handle(Input::Delete);
|
||
assert_eq!(editor.context().text, "界");
|
||
editor.handle(Input::Char('a'));
|
||
type_text(&mut editor, "👨👩👧");
|
||
editor.handle(Input::Backspace);
|
||
assert_eq!(editor.context().text, "界");
|
||
editor.handle(Input::Escape);
|
||
assert_eq!(editor.cursor().column, 0);
|
||
assert_eq!(editor.context().column, 1);
|
||
}
|
||
|
||
#[test]
|
||
fn insertion_merges_combining_graphemes_and_joins_lines() {
|
||
let mut editor = editor();
|
||
editor.handle(Input::Char('i'));
|
||
type_text(&mut editor, "a\u{301}\nb");
|
||
editor.handle(Input::Home);
|
||
editor.handle(Input::Backspace);
|
||
assert_eq!(editor.context().text, "a\u{301}b");
|
||
assert_eq!(editor.cursor(), Cursor { row: 0, column: 1 });
|
||
editor.handle(Input::Enter);
|
||
editor.handle(Input::Up);
|
||
editor.handle(Input::End);
|
||
editor.handle(Input::Delete);
|
||
assert_eq!(editor.context().text, "a\u{301}b");
|
||
editor.handle(Input::Escape);
|
||
editor.handle(Input::Char('u'));
|
||
assert_eq!(editor.context().text, "");
|
||
}
|
||
|
||
#[test]
|
||
fn cursor_is_clamped_on_short_and_empty_lines() {
|
||
let mut editor = editor();
|
||
editor
|
||
.apply(vec![Effect::Replace("long\nx\n".into())])
|
||
.unwrap();
|
||
editor.handle(Input::End);
|
||
assert_eq!(editor.cursor().column, 3);
|
||
editor.handle(Input::Down);
|
||
assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 });
|
||
editor.handle(Input::Char('j'));
|
||
editor.handle(Input::Char('l'));
|
||
editor.handle(Input::Char('x'));
|
||
assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 });
|
||
assert_eq!(editor.context().row, 3);
|
||
editor.handle(Input::Char('i'));
|
||
editor.handle(Input::Left);
|
||
editor.handle(Input::Delete);
|
||
editor.handle(Input::Escape);
|
||
assert_eq!(editor.context().text, "long\nx\n");
|
||
}
|
||
|
||
#[test]
|
||
fn registry_rejects_incompatible_duplicate_and_forged_core_atomically() {
|
||
let mut editor = editor();
|
||
let mut feature = plugin("graphics", &["draw"]);
|
||
feature.required_capabilities.push("graphics".into());
|
||
assert!(editor.register(feature).is_err());
|
||
assert!(editor.bind("normal", "g", Some("draw".into())).is_err());
|
||
assert!(
|
||
editor
|
||
.register(plugin("duplicate", &["newcmd", "w"]))
|
||
.is_err()
|
||
);
|
||
assert!(editor.bind("normal", "g", Some("newcmd".into())).is_err());
|
||
assert!(
|
||
editor
|
||
.register(plugin("repeat", &["same", "same"]))
|
||
.is_err()
|
||
);
|
||
assert!(editor.register(plugin("invalid", &["bad name"])).is_err());
|
||
assert!(editor.register(plugin("", &["emptyname"])).is_err());
|
||
let mut feature = plugin("forged", &["override"]);
|
||
feature.core = true;
|
||
assert!(editor.register(feature).is_err());
|
||
editor.register(plugin("working", &["greet"])).unwrap();
|
||
assert!(editor.register(plugin("working", &["different"])).is_err());
|
||
assert!(editor.register(plugin("other", &["greet"])).is_err());
|
||
assert!(editor.register(plugin("collision", &["plugins"])).is_err());
|
||
editor.disable_plugin("core", "attempt");
|
||
editor.execute("q");
|
||
assert!(editor.should_quit());
|
||
}
|
||
|
||
#[test]
|
||
fn plugins_command_lists_active_features_and_capabilities_deterministically() {
|
||
let mut editor = editor();
|
||
editor.register(plugin("zebra", &["last"])).unwrap();
|
||
editor.register(plugin("alpha", &["first"])).unwrap();
|
||
editor.execute("plugins");
|
||
assert_eq!(
|
||
editor.message(),
|
||
"Capabilities: text | alpha: first | core [core]: w, q, q!, wq, plugins | zebra: last"
|
||
);
|
||
editor.disable_plugin("alpha", "fault");
|
||
editor.execute("plugins");
|
||
assert!(!editor.message().contains("alpha"));
|
||
assert!(editor.message().contains("core [core]"));
|
||
}
|
||
|
||
#[test]
|
||
fn readable_key_aliases_share_bindings_and_invalid_chords_leave_them_intact() {
|
||
let mut editor = editor();
|
||
editor.bind("normal", "shift+i", None).unwrap();
|
||
editor.handle(Input::Char('I'));
|
||
assert_eq!(editor.mode(), Mode::Normal);
|
||
editor
|
||
.bind("normal", "I", Some("core.insert".into()))
|
||
.unwrap();
|
||
editor.handle(Input::Char('I'));
|
||
assert_eq!(editor.mode(), Mode::Insert);
|
||
|
||
editor
|
||
.bind("insert", "plus", Some("core.newline".into()))
|
||
.unwrap();
|
||
editor.bind("insert", "+", None).unwrap();
|
||
editor.handle(Input::Char('+'));
|
||
type_text(&mut editor, "界");
|
||
assert_eq!(editor.lines(), &["界"]);
|
||
editor
|
||
.bind("insert", "plus", Some("core.newline".into()))
|
||
.unwrap();
|
||
editor.handle(Input::Char('+'));
|
||
assert_eq!(editor.lines(), &["界", ""]);
|
||
|
||
editor.bind("insert", "CTRL+I", None).unwrap();
|
||
editor.handle(Input::Char('\t'));
|
||
assert_eq!(editor.lines(), &["界", ""]);
|
||
editor
|
||
.bind("insert", "TAB", Some("core.newline".into()))
|
||
.unwrap();
|
||
editor.handle(Input::Ctrl('i'));
|
||
assert_eq!(editor.lines(), &["界", "", ""]);
|
||
|
||
editor
|
||
.bind("insert", "shift+j", Some("core.normal".into()))
|
||
.unwrap();
|
||
for key in [
|
||
"",
|
||
"<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.execute("w");
|
||
assert_eq!(fs::read_to_string(&path).unwrap(), text);
|
||
assert!(!editor.dirty());
|
||
}
|
||
let path = directory.path("new file");
|
||
let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap();
|
||
editor.handle(Input::Char('i'));
|
||
type_text(&mut editor, "saved\n");
|
||
editor.handle(Input::Escape);
|
||
editor.execute("q");
|
||
assert!(!editor.should_quit());
|
||
editor.execute("w");
|
||
assert_eq!(fs::read_to_string(&path).unwrap(), "saved\n");
|
||
assert!(!editor.dirty());
|
||
editor.handle(Input::Char('u'));
|
||
assert!(editor.dirty());
|
||
editor.handle(Input::Char('U'));
|
||
assert!(!editor.dirty());
|
||
editor.execute("q");
|
||
assert!(editor.should_quit());
|
||
}
|
||
|
||
#[test]
|
||
fn save_as_and_failed_wq_keep_path_dirty_state_and_quit_correct() {
|
||
let directory = Directory::new();
|
||
let mut editor = editor();
|
||
editor
|
||
.apply(vec![Effect::Insert("unsaved".into())])
|
||
.unwrap();
|
||
editor.execute("w");
|
||
assert!(editor.dirty());
|
||
let bad = directory.path("absent/file");
|
||
editor.execute(&format!("wq {}", bad.display()));
|
||
assert!(editor.dirty());
|
||
assert!(!editor.should_quit());
|
||
assert_eq!(editor.path(), None);
|
||
let path = directory.path("save as.txt");
|
||
editor.execute(&format!("w {}", path.display()));
|
||
assert_eq!(editor.path(), Some(path.as_path()));
|
||
assert!(!editor.dirty());
|
||
editor.apply(vec![Effect::Insert("new".into())]).unwrap();
|
||
editor.execute("q!");
|
||
assert!(editor.should_quit());
|
||
assert_eq!(fs::read_to_string(path).unwrap(), "unsaved");
|
||
}
|
||
|
||
#[test]
|
||
fn loaded_controls_are_rejected_and_cr_line_endings_are_normalized() {
|
||
let directory = Directory::new();
|
||
let path = directory.path("control");
|
||
for text in ["nul\0", "escape\u{1b}[31m", "backspace\u{8}", "c1\u{85}"] {
|
||
fs::write(&path, text).unwrap();
|
||
assert!(
|
||
matches!(Editor::open(Some(path.clone()), vec![]), Err(error) if error.kind() == io::ErrorKind::InvalidData)
|
||
);
|
||
}
|
||
fs::write(&path, "a\t界\r\nb\rc\n").unwrap();
|
||
let editor = Editor::open(Some(path), vec![]).unwrap();
|
||
assert_eq!(editor.context().text, "a\t界\nb\nc\n");
|
||
assert!(!editor.dirty());
|
||
assert!(Editor::open(Some(directory.0.clone()), vec![]).is_err());
|
||
}
|
||
|
||
#[cfg(unix)]
|
||
#[test]
|
||
fn saves_preserve_permissions_and_reject_symlinks_without_replacing_them() {
|
||
use std::os::unix::fs::{PermissionsExt, symlink};
|
||
let directory = Directory::new();
|
||
let path = directory.path("original");
|
||
fs::write(&path, "original").unwrap();
|
||
fs::set_permissions(&path, fs::Permissions::from_mode(0o640)).unwrap();
|
||
let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap();
|
||
editor
|
||
.apply(vec![Effect::Replace("changed".into())])
|
||
.unwrap();
|
||
editor.execute("w");
|
||
assert_eq!(
|
||
fs::metadata(&path).unwrap().permissions().mode() & 0o777,
|
||
0o640
|
||
);
|
||
let link = directory.path("link");
|
||
symlink(&path, &link).unwrap();
|
||
let mut editor = Editor::open(Some(link.clone()), vec![]).unwrap();
|
||
editor
|
||
.apply(vec![Effect::Replace("not saved".into())])
|
||
.unwrap();
|
||
editor.execute("wq");
|
||
assert!(editor.dirty());
|
||
assert!(!editor.should_quit());
|
||
assert!(
|
||
fs::symlink_metadata(&link)
|
||
.unwrap()
|
||
.file_type()
|
||
.is_symlink()
|
||
);
|
||
assert_eq!(fs::read_to_string(&path).unwrap(), "changed");
|
||
assert_eq!(fs::read_dir(&directory.0).unwrap().count(), 2);
|
||
}
|
||
}
|