//! 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 { 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 { 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" | "" => { 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 { 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 { if key.alt { return None; } if key.ctrl { return match key.code { KeyCode::Char(ch @ ('r' | 'd' | 'u')) => Some(format!("")), _ => 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 => "".into(), KeyCode::PageDown => "".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)); } }