Files
notescraft/crates/vim/src/command.rs
T
Marcos Paulo 81cefc9cf8 wip
2026-10-14 17:42:34 -03:00

297 lines
12 KiB
Rust

//! Keyboard dispatch and normal-mode commands.
use crate::motion::{self, Find};
use crate::parse::{self, MAX_COUNT, Parsed};
use crate::{Doc, Error, ExCommand, Key, KeyCode, Mode, Outcome, Result, Vim};
impl Vim {
pub(crate) fn handle_key(&mut self, key: Key, doc: &mut Doc, out: &mut Outcome) -> Result<bool> {
if self.invalid_count {
if !matches!(key.code, KeyCode::Char(ch) if ch.is_ascii_digit()) {
self.invalid_count = false;
}
return Ok(true);
}
if self.mode == Mode::CommandLine {
return self.command_key(key, doc, out);
}
if matches!(self.mode, Mode::Insert | Mode::Replace) {
return self.insert_key(key, doc);
}
if key.code == KeyCode::Esc || key.ctrl && matches!(key.code, KeyCode::Char('[' | 'c')) {
self.pending.clear();
self.mode = Mode::Normal;
doc.anchor = None;
doc.normal_cursor();
return Ok(true);
}
let Some(token) = key_token(key) else {
return Ok(false);
};
let visual = matches!(self.mode, Mode::Visual | Mode::VisualLine);
if self.pending.is_empty() && [":", "/", "?"].contains(&token.as_str()) {
self.prompt = token.chars().next().unwrap_or(':');
self.mode = Mode::CommandLine;
self.command_line.clear();
return Ok(true);
}
self.pending.push_str(&token);
let Some(parsed) = parse::parse(&self.pending, visual)? else {
return Ok(true);
};
self.pending.clear();
self.execute(&parsed, doc, out)
}
fn execute(&mut self, parsed: &Parsed, doc: &mut Doc, out: &mut Outcome) -> Result<bool> {
if let Some(operator) = parsed.operator {
return self.apply_operator(operator, parsed, doc, out).map(|()| true);
}
let command = parsed.command.as_str();
if matches!(self.mode, Mode::Visual | Mode::VisualLine) && ["~", "u", "U"].contains(&command) {
let operator = match command {
"u" => parse::Operator::Lower,
"U" => parse::Operator::Upper,
_ => parse::Operator::Toggle,
};
return self
.apply_operator(
operator,
&Parsed {
count: parsed.count,
explicit: parsed.explicit,
register: parsed.register,
operator: Some(operator),
command: "selection".into(),
},
doc,
out,
)
.map(|()| true);
}
let count = parsed.count;
match command {
"i" => self.enter_insert(Mode::Insert, count),
"a" => {
doc.cursor = doc.next(doc.cursor);
self.enter_insert(Mode::Insert, count);
}
"I" => {
doc.cursor = first_nonblank(doc, doc.cursor);
self.enter_insert(Mode::Insert, count);
}
"A" => {
doc.cursor = doc.line_end(doc.cursor);
self.enter_insert(Mode::Insert, count);
}
"R" => self.enter_insert(Mode::Replace, count),
"o" | "O" => self.open_line(doc, command == "o", count)?,
"v" | "V" => {
let target = if command == "v" { Mode::Visual } else { Mode::VisualLine };
if self.mode == target {
self.mode = Mode::Normal;
doc.anchor = None;
} else {
if doc.anchor.is_none() {
doc.anchor = Some(doc.cursor);
}
self.mode = target;
}
}
"x" | "X" | "s" | "S" | "C" | "D" | "Y" => self.single_operator(parsed, doc, out)?,
"p" | "P" => self.paste(doc, parsed, command == "p")?,
"J" => Self::join(doc, count)?,
"~" => Self::toggle(doc, count)?,
"u" | "<C-r>" => {
self.repeat.history_action = true;
for _ in 0..count {
let changed = if command == "u" { self.history.undo(doc) } else { self.history.redo(doc) };
if !changed {
break;
}
}
doc.anchor = None;
doc.normal_cursor();
}
"." => self.dot(doc, out, count)?,
"n" | "N" => self.search_next(doc, command == "N", count)?,
"*" => {
self.search.word(doc)?;
self.search_next(doc, false, count)?;
}
"gx" | "gf" => {
if has_link(doc, command == "gf") {
out.ex = Some(if command == "gf" { ExCommand::OpenWikilink } else { ExCommand::FollowLink });
} else {
return Err(Error::Invalid("no link under cursor".into()));
}
}
"za" => out.ex = Some(ExCommand::Fold),
"gt" => out.ex = Some(ExCommand::NextBuffer),
"gT" => out.ex = Some(ExCommand::PrevBuffer),
_ => return self.other_command(parsed, doc),
}
Ok(true)
}
fn other_command(&mut self, parsed: &Parsed, doc: &mut Doc) -> Result<bool> {
let mut chars = parsed.command.chars();
let first = chars.next();
let second = chars.next();
if first == Some('m') && second.is_some_and(|mark| mark.is_ascii_lowercase()) && chars.next().is_none() {
if let Some(mark) = second {
self.marks.insert(mark, doc.cursor);
}
return Ok(true);
}
if matches!(first, Some('\'' | '`')) && chars.next().is_none() {
let mark = second.ok_or_else(|| Error::Invalid("missing mark".into()))?;
let position = *self.marks.get(&mark).ok_or_else(|| Error::Invalid("mark not set".into()))?;
if !doc.text.is_char_boundary(position) {
return Err(Error::Invalid("mark outside document".into()));
}
doc.cursor = if first == Some('\'') { first_nonblank(doc, position) } else { position };
return Ok(true);
}
if first == Some('r') && chars.next().is_none() {
return Self::replace_character(doc, second, parsed.count).map(|()| true);
}
if matches!(self.mode, Mode::Visual | Mode::VisualLine) && matches!(first, Some('i' | 'a')) && chars.next().is_none() {
let range = second
.and_then(|kind| crate::textobject::range(doc, kind, first == Some('a'), parsed.count))
.ok_or_else(|| Error::Invalid("text object not found".into()))?;
doc.anchor = Some(range.start);
doc.cursor = doc.prev(range.end);
return Ok(true);
}
let Some(target) = motion::resolve(doc, &parsed.command, parsed.count, parsed.explicit, self.viewport, self.last_find) else {
return Ok(false);
};
self.remember_find(&parsed.command);
doc.cursor = target.target;
doc.normal_cursor();
Ok(true)
}
pub(crate) fn remember_find(&mut self, command: &str) {
let mut chars = command.chars();
if let (Some(kind @ ('f' | 'F' | 't' | 'T')), Some(needle)) = (chars.next(), chars.next()) {
self.last_find = Some(Find { needle, forward: kind.is_lowercase(), till: matches!(kind, 't' | 'T') });
}
}
pub(crate) fn search_next(&self, doc: &mut Doc, reverse: bool, count: usize) -> Result<()> {
doc.cursor = self.search.find(doc, reverse, count).ok_or_else(|| Error::Invalid("pattern not found".into()))?;
Ok(())
}
fn dot(&mut self, doc: &mut Doc, out: &mut Outcome, count: usize) -> Result<()> {
if self.repeat.replaying {
return Err(Error::Invalid("recursive repeat".into()));
}
self.repeat.dot_action = true;
let keys = self.repeat.last.clone();
if keys.len().checked_mul(count).is_none_or(|size| size > MAX_COUNT) {
return Err(Error::Limit("repeat execution"));
}
self.repeat.replaying = true;
for _ in 0..count {
for key in &keys {
let effect = self.handle(*key, doc);
if effect.message.is_some() {
self.repeat.replaying = false;
return Err(Error::Invalid(effect.message.unwrap_or_default()));
}
if effect.clipboard_set.is_some() {
out.clipboard_set = effect.clipboard_set;
}
}
}
self.repeat.replaying = false;
Ok(())
}
}
pub(crate) fn first_nonblank(doc: &Doc, pos: usize) -> usize {
let start = doc.line_start(pos);
doc.slice(start, doc.line_end(start))
.and_then(|line| line.char_indices().find(|(_, ch)| !ch.is_whitespace()))
.map_or(start, |(offset, _)| start.saturating_add(offset))
}
fn key_token(key: Key) -> Option<String> {
if key.alt {
return None;
}
if key.ctrl {
return match key.code {
KeyCode::Char(ch @ ('r' | 'd' | 'u')) => Some(format!("<C-{ch}>")),
_ => None,
};
}
Some(match key.code {
KeyCode::Char(ch) => ch.to_string(),
KeyCode::Left | KeyCode::Backspace => "h".into(),
KeyCode::Right => "l".into(),
KeyCode::Up => "k".into(),
KeyCode::Down | KeyCode::Enter => "j".into(),
KeyCode::Home => "0".into(),
KeyCode::End => "$".into(),
KeyCode::PageUp => "<C-u>".into(),
KeyCode::PageDown => "<C-d>".into(),
KeyCode::Tab | KeyCode::F(_) | KeyCode::Esc => return None,
})
}
fn has_link(doc: &Doc, wiki_only: bool) -> bool {
let start = doc.line_start(doc.cursor);
let end = doc.line_end(doc.cursor);
let Some(line) = doc.slice(start, end) else {
return false;
};
let cursor = doc.cursor.saturating_sub(start);
for (offset, _) in line.match_indices("[[") {
if let Some(close) = line.get(offset.saturating_add(2)..).and_then(|tail| tail.find("]]")) {
let finish = offset.saturating_add(2).saturating_add(close).saturating_add(2);
if cursor >= offset && cursor < finish {
return true;
}
}
}
if wiki_only {
return false;
}
for (offset, _) in line.match_indices('[') {
if let Some(tail) = line.get(offset..)
&& let Some(separator) = tail.find("](")
&& let Some(close) = tail.get(separator.saturating_add(2)..).and_then(|part| part.find(')'))
{
let finish = offset.saturating_add(separator).saturating_add(2).saturating_add(close).saturating_add(1);
if cursor >= offset && cursor < finish {
return true;
}
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn host_modifiers_are_not_inserted_into_pending_commands() {
let mut vim = Vim::new();
let mut doc = Doc::new("text".into()).unwrap();
let effect = vim.handle(Key { alt: true, ..Key::char('x') }, &mut doc);
assert!(!effect.consumed);
assert_eq!(vim.pending(), "");
}
#[test]
fn markdown_effects_require_a_link_at_cursor() {
let mut doc = Doc::new("[label](note) outside".into()).unwrap();
assert!(has_link(&doc, false));
assert!(!has_link(&doc, true));
doc.cursor = doc.text.find("outside").unwrap();
assert!(!has_link(&doc, false));
}
}