use std::path::PathBuf;
use super::{format_dirty_list, is_inclusive_motion, word_under_cursor};
use crate::action::{
Ctx, DirectKind, Expr, LastFind, MotionExpr, MotionKind, Operator, PromptKind, Target,
};
use crate::app::App;
use crate::buffer_ref::BufferRef;
use crate::effect::{Cmd, ScrollAnchor};
use crate::mode::Mode;
impl App {
pub(super) fn handle_expr(&mut self, expr: Expr, ctx: Ctx) -> Vec<Cmd> {
if expr_modifies_buffer(&expr) {
self.buffer.snapshot();
}
self.handle_expr_no_snapshot(expr, ctx)
}
pub(super) fn handle_expr_no_snapshot(&mut self, expr: Expr, ctx: Ctx) -> Vec<Cmd> {
match expr {
Expr::Direct { kind, count } => self.handle_direct(kind, count, ctx),
Expr::Motion(m) => self.handle_motion(m),
Expr::Op {
op,
target,
outer_count,
} => self.handle_op(op, target, outer_count),
}
}
fn handle_direct(&mut self, kind: DirectKind, count: u32, ctx: Ctx) -> Vec<Cmd> {
use DirectKind as D;
let mut cmds = Vec::new();
match kind {
D::EnterMode(m) => cmds.push(Cmd::EnterMode(m)),
D::OpenPrompt(k) => cmds.push(Cmd::OpenPrompt(k)),
D::OpenLineBelow => {
let indent = self.indent_settings();
self.buffer.insert_line_below(indent);
cmds.push(Cmd::EnterMode(Mode::Insert));
}
D::OpenLineAbove => {
let indent = self.indent_settings();
self.buffer.insert_line_above(indent);
cmds.push(Cmd::EnterMode(Mode::Insert));
}
D::AppendAfterCursor => {
self.buffer.move_right(true);
cmds.push(Cmd::EnterMode(Mode::Insert));
}
D::AppendAtLineEnd => {
self.buffer.cursor.col = self.buffer.current_line_len();
cmds.push(Cmd::EnterMode(Mode::Insert));
}
D::InsertAtLineStart => {
let line = self.buffer.current_line();
let col = line
.chars()
.position(|c| !c.is_whitespace())
.unwrap_or(0);
self.buffer.cursor.col = col;
cmds.push(Cmd::EnterMode(Mode::Insert));
}
D::ChangeToEol => {
self.buffer.delete_to_eol();
cmds.push(Cmd::EnterMode(Mode::Insert));
}
D::DeleteToEol => self.buffer.delete_to_eol(),
D::YankLine => {
for _ in 0..count {
self.buffer.yank_line();
}
cmds.push(Cmd::SyncYank);
cmds.push(Cmd::ToastInfo("yanked".into()));
}
D::JoinLines => {
for _ in 0..count {
self.buffer.join_next_line();
}
}
D::ToggleCase => {
for _ in 0..count {
self.buffer.toggle_case_under_cursor();
}
}
D::SubstituteChar => {
for _ in 0..count {
self.buffer.delete_char_under_cursor();
}
cmds.push(Cmd::EnterMode(Mode::Insert));
}
D::SubstituteLine => {
self.buffer.clear_current_line();
cmds.push(Cmd::EnterMode(Mode::Insert));
}
D::ReplaceChar { ch } => {
for _ in 0..count {
self.buffer.replace_char(ch);
self.buffer.move_right(false);
}
self.buffer.move_left();
}
D::ViewportCenter => cmds.push(Cmd::Scroll(ScrollAnchor::Center)),
D::ViewportTopAtCursor => cmds.push(Cmd::Scroll(ScrollAnchor::Top)),
D::ViewportBottomAtCursor => cmds.push(Cmd::Scroll(ScrollAnchor::Bottom)),
D::Paste => {
for _ in 0..count {
self.buffer.paste_after();
}
}
D::Undo => {
if !self.buffer.undo() {
cmds.push(Cmd::ToastError("already at oldest change".into()));
}
}
D::Redo => {
if !self.buffer.redo() {
cmds.push(Cmd::ToastError("already at newest change".into()));
}
}
D::DeleteCharUnderCursor => {
for _ in 0..count {
self.buffer.delete_char_under_cursor();
}
}
D::Quit => cmds.push(plan_quit(self)),
D::QuitForce => cmds.push(Cmd::Quit),
D::BufferNext => cmds.push(Cmd::BufferCycle { forward: true }),
D::BufferPrev => cmds.push(Cmd::BufferCycle { forward: false }),
D::BufferDelete => cmds.push(Cmd::BufferDelete { force: false }),
D::BufferDeleteForce => cmds.push(Cmd::BufferDelete { force: true }),
D::BufferList => {
cmds.push(Cmd::OpenPrompt(PromptKind::Fuzzy(
crate::finder::FuzzyKind::Buffers,
)));
}
D::SaveAndQuit => cmds.push(Cmd::Save {
path: parse_save_path(ctx.rest),
then_quit: true,
}),
D::Save => cmds.push(Cmd::Save {
path: parse_save_path(ctx.rest),
then_quit: false,
}),
D::Open => {
if ctx.rest.is_empty() {
cmds.push(Cmd::ToastError("missing path".into()));
} else {
cmds.push(Cmd::OpenPath(PathBuf::from(ctx.rest)));
}
}
D::GotoLine => match ctx.rest.parse::<usize>() {
Ok(n) if n >= 1 => self.goto_line_n_pure(n),
_ => cmds.push(Cmd::ToastError("usage: :goto <line>".into())),
},
D::GotoDefinition => cmds.push(Cmd::LspJump {
method: "textDocument/definition",
label: "definition",
}),
D::GotoDeclaration => cmds.push(Cmd::LspJump {
method: "textDocument/declaration",
label: "declaration",
}),
D::GotoImplementation => cmds.push(Cmd::LspJump {
method: "textDocument/implementation",
label: "implementation",
}),
D::FindReferences => cmds.push(Cmd::LspFindReferences),
D::Rename => cmds.push(Cmd::OpenRenamePrompt),
D::CodeAction => cmds.push(Cmd::LspCodeAction),
D::Hover => cmds.push(Cmd::LspHover),
D::RepeatLast => unreachable!("RepeatLast handled in App::evaluate"),
D::SearchSelectNext { reverse } => {
cmds.push(Cmd::SearchSelectMatch { reverse });
}
D::SearchWordKeep { forward } => {
push_word_search(self, &mut cmds, forward, false);
}
D::ClearSearch => {
cmds.push(Cmd::SetSearch {
pattern: String::new(),
forward: true,
});
}
D::MultiCursorAddNext => add_next_cursor(self, &mut cmds),
D::MultiCursorPop => {
if let Some(c) = self.buffer.extra_cursors.pop() {
self.buffer.cursor = c;
} else {
cmds.push(Cmd::ToastInfo("no extra cursor to remove".into()));
}
}
D::MultiCursorClear => {
if self.buffer.extra_cursors.is_empty() {
cmds.push(Cmd::ToastInfo("no extra cursors".into()));
} else {
let n = self.buffer.extra_cursors.len();
self.buffer.extra_cursors.clear();
cmds.push(Cmd::ToastInfo(format!("cleared {n} extra cursors")));
}
}
D::JumpLabel => cmds.push(Cmd::StartJumpLabel),
D::SelectWholeBuffer => cmds.push(Cmd::SelectWholeBuffer),
D::ToggleComment => match buffer_comment_token(self) {
Some(token) => {
let start_row = self.buffer.cursor.row;
let start_col = self.buffer.cursor.col;
let max = self.buffer.lines.len();
for i in 0..count {
self.buffer.toggle_line_comment(&token);
if i + 1 < count && self.buffer.cursor.row + 1 < max {
self.buffer.cursor.row += 1;
}
}
self.buffer.cursor.row = start_row;
self.buffer.cursor.col = start_col;
self.buffer.clamp_col(false);
}
None => cmds.push(Cmd::ToastError(
"no comment token for this buffer".into(),
)),
},
}
cmds
}
fn handle_motion(&mut self, m: MotionExpr) -> Vec<Cmd> {
use MotionKind as M;
let mut cmds = Vec::new();
let allow_after = matches!(self.mode, Mode::Insert);
let n = m.count;
let (resolved, last_find_update) = resolve_motion_pure(m.motion, self.last_find);
if let Some(lf) = last_find_update {
cmds.push(Cmd::SetLastFind(lf));
}
let Some(resolved) = resolved else {
cmds.push(Cmd::ToastError("no previous find".into()));
return cmds;
};
match resolved {
M::Left => {
for _ in 0..n {
self.buffer.move_left();
}
}
M::Right => {
for _ in 0..n {
self.buffer.move_right(allow_after);
}
}
M::Up => {
for _ in 0..n {
self.buffer.move_up();
}
}
M::Down => {
for _ in 0..n {
self.buffer.move_down();
}
}
M::LineStart => self.buffer.move_line_start(),
M::LineEnd => self.buffer.move_line_end(),
M::SearchWordForward => push_word_search(self, &mut cmds, true, true),
M::SearchWordBack => push_word_search(self, &mut cmds, false, true),
M::WordForward => {
for _ in 0..n {
self.buffer.move_word_forward();
}
}
M::WordBack => {
for _ in 0..n {
self.buffer.move_word_backward();
}
}
M::WordEnd
| M::BigWordForward
| M::BigWordBack
| M::BigWordEnd
| M::WordEndBack
| M::BigWordEndBack
| M::LineFirstNonBlank
| M::LineLastNonBlank
| M::BracketMatch
| M::FindChar { .. }
| M::ViewportTop
| M::ViewportMiddle
| M::ViewportBottom
| M::HalfPageDown
| M::HalfPageUp
| M::PageDown
| M::PageUp => {
let target = self.buffer.motion_target(self.buffer.cursor, resolved, n);
self.buffer.cursor = target;
}
M::RepeatFind { .. } => {}
M::FileStart => {
if n > 1 {
self.goto_line_n_pure(n as usize);
} else {
self.buffer.move_file_start();
}
}
M::FileEnd => {
if n > 1 {
self.goto_line_n_pure(n as usize);
} else {
self.buffer.move_file_end();
}
}
M::SearchNext => {
for _ in 0..n {
cmds.push(Cmd::JumpSearch { reverse: false });
}
}
M::SearchPrev => {
for _ in 0..n {
cmds.push(Cmd::JumpSearch { reverse: true });
}
}
M::ParagraphForward => {
for _ in 0..n {
self.buffer.move_paragraph_forward();
}
}
M::ParagraphBack => {
for _ in 0..n {
self.buffer.move_paragraph_backward();
}
}
}
cmds
}
fn handle_op(&mut self, op: Operator, target: Target, outer_count: u32) -> Vec<Cmd> {
let mut cmds = Vec::new();
match target {
Target::LineWise => {
if matches!(op, Operator::Indent | Operator::Dedent) {
let indent = self.indent_settings();
let start_row = self.buffer.cursor.row;
let last = self.buffer.lines.len().saturating_sub(1);
let span = outer_count.max(1) as usize - 1;
let end_row = start_row.saturating_add(span).min(last);
for r in start_row..=end_row {
if matches!(op, Operator::Indent) {
self.buffer.indent_line(r, indent);
} else {
self.buffer.dedent_line(r, indent);
}
}
self.buffer.cursor.row = start_row;
cursor_to_first_non_blank(&mut self.buffer);
} else {
for _ in 0..outer_count {
match op {
Operator::Delete => self.buffer.delete_line(),
Operator::Yank => {
self.buffer.yank_line();
cmds.push(Cmd::SyncYank);
cmds.push(Cmd::ToastInfo("yanked".into()));
}
Operator::Change => {
cmds.push(Cmd::ToastError("change not implemented yet".into()));
}
Operator::Indent | Operator::Dedent => unreachable!(),
}
}
}
}
Target::Motion(m) => {
let (resolved, last_find_update) = resolve_motion_pure(m.motion, self.last_find);
if let Some(lf) = last_find_update {
cmds.push(Cmd::SetLastFind(lf));
}
let Some(resolved) = resolved else {
cmds.push(Cmd::ToastError("no previous find".into()));
return cmds;
};
let inclusive = is_inclusive_motion(resolved);
for _ in 0..outer_count {
let start = self.buffer.cursor;
let target = self.buffer.motion_target(start, resolved, m.count);
let end = if inclusive {
self.buffer.advance_one(target)
} else {
target
};
self.apply_op_range_handle(op, start, end, &mut cmds);
}
}
Target::SearchMatch { reverse } => {
let forward = self.search.last_forward ^ reverse;
for _ in 0..outer_count {
let Some((start, end_incl)) =
self.search.find_match_range(&self.buffer, forward)
else {
cmds.push(Cmd::ToastError("pattern not found".into()));
break;
};
let end = self.buffer.advance_one(end_incl);
self.apply_op_range_handle(op, start, end, &mut cmds);
}
}
Target::TextObject { scope, object } => {
for _ in 0..outer_count {
match self.buffer.text_object_range(scope, object) {
Some((start, end)) => self.apply_op_range_handle(op, start, end, &mut cmds),
None => {
cmds.push(Cmd::ToastError("no matching object".into()));
break;
}
}
}
}
}
cmds
}
fn apply_op_range_handle(
&mut self,
op: Operator,
start: crate::editor::Cursor,
end: crate::editor::Cursor,
cmds: &mut Vec<Cmd>,
) {
match op {
Operator::Delete => self.buffer.delete_range(start, end),
Operator::Yank => {
self.buffer.yank_range(start, end);
cmds.push(Cmd::SyncYank);
cmds.push(Cmd::ToastInfo("yanked".into()));
}
Operator::Change => {
self.buffer.delete_range(start, end);
cmds.push(Cmd::EnterMode(Mode::Insert));
}
Operator::Indent | Operator::Dedent => {
let indent = self.indent_settings();
let (lo, hi) = if (start.row, start.col) <= (end.row, end.col) {
(start.row, end.row)
} else {
(end.row, start.row)
};
for r in lo..=hi {
if matches!(op, Operator::Indent) {
self.buffer.indent_line(r, indent);
} else {
self.buffer.dedent_line(r, indent);
}
}
self.buffer.cursor.row = lo;
cursor_to_first_non_blank(&mut self.buffer);
}
}
}
fn goto_line_n_pure(&mut self, n: usize) {
let last = self.buffer.lines.len().saturating_sub(1);
self.buffer.cursor.row = n.saturating_sub(1).min(last);
self.buffer.cursor.col = 0;
self.buffer.clamp_col(false);
}
}
fn resolve_motion_pure(
motion: MotionKind,
last_find: Option<LastFind>,
) -> (Option<MotionKind>, Option<LastFind>) {
use MotionKind as M;
match motion {
M::RepeatFind { reverse } => {
let Some(lf) = last_find else {
return (None, None);
};
let forward = if reverse { !lf.forward } else { lf.forward };
(
Some(M::FindChar {
ch: lf.ch,
forward,
till: lf.till,
}),
None,
)
}
M::FindChar { ch, forward, till } => (
Some(motion),
Some(LastFind { ch, forward, till }),
),
_ => (Some(motion), None),
}
}
fn push_word_search(app: &App, cmds: &mut Vec<Cmd>, forward: bool, jump: bool) {
match word_under_cursor(&app.buffer) {
Some(word) => {
cmds.push(Cmd::SetSearch {
pattern: word,
forward,
});
if jump {
cmds.push(Cmd::JumpSearch { reverse: false });
}
}
None => cmds.push(Cmd::ToastError("no word under cursor".into())),
}
}
fn plan_quit(app: &App) -> Cmd {
if app.buffer.dirty {
return Cmd::ToastError("unsaved changes (use :q!)".into());
}
let sleeping_dirty: Vec<&BufferRef> = app
.sleeping
.iter()
.filter(|(_, b)| b.dirty)
.map(|(r, _)| r)
.collect();
if !sleeping_dirty.is_empty() {
return Cmd::ToastError(format!(
"unsaved changes in {} (use :q!)",
format_dirty_list(&sleeping_dirty)
));
}
Cmd::Quit
}
fn add_next_cursor(app: &mut App, cmds: &mut Vec<Cmd>) {
let Some(word) = word_under_cursor(&app.buffer) else {
cmds.push(Cmd::ToastError("no word under cursor".into()));
return;
};
let mut tmp = crate::editor::SearchState::default();
tmp.set(word.clone(), true);
let Some(next) = tmp.find_next(&app.buffer, true) else {
cmds.push(Cmd::ToastError("no further match".into()));
return;
};
let primary = app.buffer.cursor;
if next == primary || app.buffer.extra_cursors.contains(&next) {
cmds.push(Cmd::ToastInfo("no further match".into()));
return;
}
app.buffer.extra_cursors.push(primary);
app.buffer.cursor = next;
cmds.push(Cmd::SetSearch {
pattern: word,
forward: true,
});
let n = app.buffer.extra_cursors.len() + 1;
cmds.push(Cmd::ToastInfo(format!("{n} cursors")));
}
fn cursor_to_first_non_blank(buf: &mut crate::editor::Buffer) {
let line = buf.current_line();
let col = line.chars().position(|c| !c.is_whitespace()).unwrap_or(0);
buf.cursor.col = col;
}
fn parse_save_path(rest: &str) -> Option<PathBuf> {
if rest.is_empty() {
None
} else {
Some(PathBuf::from(rest))
}
}
pub(super) fn expr_modifies_buffer(expr: &Expr) -> bool {
use DirectKind as D;
match expr {
Expr::Direct { kind, .. } => matches!(
kind,
D::OpenLineBelow
| D::OpenLineAbove
| D::Paste
| D::DeleteCharUnderCursor
| D::EnterMode(Mode::Insert)
| D::AppendAfterCursor
| D::AppendAtLineEnd
| D::InsertAtLineStart
| D::ChangeToEol
| D::DeleteToEol
| D::JoinLines
| D::ToggleCase
| D::SubstituteChar
| D::SubstituteLine
| D::ReplaceChar { .. }
| D::ToggleComment
),
Expr::Motion(_) => false,
Expr::Op { op, .. } => !matches!(op, Operator::Yank),
}
}
fn buffer_comment_token(app: &App) -> Option<String> {
let path = app.buffer.path.as_ref()?;
let ext = path.extension()?.to_str()?;
let lang = app.config.languages.by_extension(ext)?;
lang.comment_token.clone()
}