From bc30521e47f92cd58a71e550889c8200326c486a Mon Sep 17 00:00:00 2001 From: pagedmov Date: Sat, 7 Mar 2026 21:57:04 -0500 Subject: [PATCH] Add `!` negation support, fix POSIX exit statuses, and improve vi emulation with comprehensive tests --- src/parse/execute.rs | 210 ++++++++++++++++++++- src/parse/lex.rs | 13 +- src/parse/mod.rs | 44 +++++ src/readline/history.rs | 19 ++ src/readline/linebuf.rs | 220 ++++++++++++++-------- src/readline/mod.rs | 256 ++++++++++++++++--------- src/readline/register.rs | 20 +- src/readline/term.rs | 32 +++- src/readline/tests.rs | 229 +++++++++++++++++++++++ src/readline/vicmd.rs | 2 +- src/readline/vimode/insert.rs | 4 + src/readline/vimode/normal.rs | 2 +- src/readline/vimode/verbatim.rs | 26 +-- src/readline/vimode/visual.rs | 31 +++- src/testutil.rs | 16 +- tests/gen_vi_tests.lua | 319 ++++++++++++++++++++++++++++++++ 16 files changed, 1235 insertions(+), 208 deletions(-) create mode 100644 src/readline/tests.rs create mode 100644 tests/gen_vi_tests.lua diff --git a/src/parse/execute.rs b/src/parse/execute.rs index 00d6426..24f262b 100644 --- a/src/parse/execute.rs +++ b/src/parse/execute.rs @@ -250,6 +250,7 @@ impl Dispatcher { NdRule::CaseNode { .. } => self.exec_case(node)?, NdRule::BraceGrp { .. } => self.exec_brc_grp(node)?, NdRule::FuncDef { .. } => self.exec_func_def(node)?, + NdRule::Negate { .. } => self.exec_negated(node)?, NdRule::Command { .. } => self.dispatch_cmd(node)?, NdRule::Test { .. } => self.exec_test(node)?, _ => unreachable!(), @@ -284,6 +285,16 @@ impl Dispatcher { self.exec_cmd(node) } } + pub fn exec_negated(&mut self, node: Node) -> ShResult<()> { + let NdRule::Negate { cmd } = node.class else { + unreachable!() + }; + self.dispatch_node(*cmd)?; + let status = state::get_status(); + state::set_status(if status == 0 { 1 } else { 0 }); + + Ok(()) + } pub fn exec_conjunction(&mut self, conjunction: Node) -> ShResult<()> { let NdRule::Conjunction { elements } = conjunction.class else { unreachable!() @@ -578,6 +589,7 @@ impl Dispatcher { } } } else { + state::set_status(0); break; } } @@ -714,9 +726,13 @@ impl Dispatcher { } } - if !matched && !else_block.is_empty() { - for node in else_block { - s.dispatch_node(node)?; + if !matched { + if !else_block.is_empty() { + for node in else_block { + s.dispatch_node(node)?; + } + } else { + state::set_status(0); } } @@ -1195,3 +1211,191 @@ pub fn is_func(tk: Option) -> bool { pub fn is_subsh(tk: Option) -> bool { tk.is_some_and(|tk| tk.flags.contains(TkFlags::IS_SUBSH)) } + +#[cfg(test)] +mod tests { + use crate::state; + use crate::testutil::{TestGuard, test_input}; + + // ===================== while/until status ===================== + + #[test] + fn while_loop_status_zero_after_completion() { + let _g = TestGuard::new(); + test_input("while false; do :; done").unwrap(); + assert_eq!(state::get_status(), 0); + } + + #[test] + fn while_loop_status_zero_after_iterations() { + let _g = TestGuard::new(); + test_input("X=0; while [[ $X -lt 3 ]]; do X=$((X+1)); done").unwrap(); + assert_eq!(state::get_status(), 0); + } + + #[test] + fn until_loop_status_zero_after_completion() { + let _g = TestGuard::new(); + test_input("until true; do :; done").unwrap(); + assert_eq!(state::get_status(), 0); + } + + #[test] + fn until_loop_status_zero_after_iterations() { + let _g = TestGuard::new(); + test_input("X=3; until [[ $X -le 0 ]]; do X=$((X-1)); done").unwrap(); + assert_eq!(state::get_status(), 0); + } + + #[test] + fn while_break_preserves_status() { + let _g = TestGuard::new(); + test_input("while true; do break; done").unwrap(); + assert_eq!(state::get_status(), 0); + } + + #[test] + fn while_body_status_propagates() { + let _g = TestGuard::new(); + test_input("X=0; while [[ $X -lt 1 ]]; do X=$((X+1)); false; done").unwrap(); + // Loop body ended with `false` (status 1), but the loop itself + // completed normally when the condition failed, so status should be 0 + assert_eq!(state::get_status(), 0); + } + + // ===================== if/elif/else status ===================== + + #[test] + fn if_true_body_status() { + let _g = TestGuard::new(); + test_input("if true; then echo ok; fi").unwrap(); + assert_eq!(state::get_status(), 0); + } + + #[test] + fn if_false_no_else_status() { + let _g = TestGuard::new(); + test_input("if false; then echo ok; fi").unwrap(); + // No branch taken, POSIX says status is 0 + assert_eq!(state::get_status(), 0); + } + + #[test] + fn if_else_branch_status() { + let _g = TestGuard::new(); + test_input("if false; then true; else false; fi").unwrap(); + assert_eq!(state::get_status(), 1); + } + + // ===================== for loop status ===================== + + #[test] + fn for_loop_empty_list_status() { + let _g = TestGuard::new(); + test_input("for x in; do echo $x; done").unwrap(); + assert_eq!(state::get_status(), 0); + } + + #[test] + fn for_loop_body_status() { + let _g = TestGuard::new(); + test_input("for x in a b c; do true; done").unwrap(); + assert_eq!(state::get_status(), 0); + } + + // ===================== case status ===================== + + #[test] + fn case_match_status() { + let _g = TestGuard::new(); + test_input("case foo in foo) true;; esac").unwrap(); + assert_eq!(state::get_status(), 0); + } + + #[test] + fn case_no_match_status() { + let _g = TestGuard::new(); + test_input("case foo in bar) true;; esac").unwrap(); + assert_eq!(state::get_status(), 0); + } + + // ===================== other stuff ===================== + + #[test] + fn for_loop_var_zip() { + let g = TestGuard::new(); + test_input("for a b in 1 2 3 4 5 6; do echo $a $b; done").unwrap(); + let out = g.read_output(); + assert_eq!(out, "1 2\n3 4\n5 6\n"); + } + + #[test] + fn for_loop_unsets_zipped() { + let g = TestGuard::new(); + test_input("for a b c d in 1 2 3 4 5 6; do echo $a $b $c $d; done").unwrap(); + let out = g.read_output(); + assert_eq!(out, "1 2 3 4\n5 6\n"); + } + + // ===================== negation (!) status ===================== + + #[test] + fn negate_true() { + let _g = TestGuard::new(); + test_input("! true").unwrap(); + assert_eq!(state::get_status(), 1); + } + + #[test] + fn negate_false() { + let _g = TestGuard::new(); + test_input("! false").unwrap(); + assert_eq!(state::get_status(), 0); + } + + #[test] + fn double_negate_true() { + let _g = TestGuard::new(); + test_input("! ! true").unwrap(); + assert_eq!(state::get_status(), 0); + } + + #[test] + fn double_negate_false() { + let _g = TestGuard::new(); + test_input("! ! false").unwrap(); + assert_eq!(state::get_status(), 1); + } + + #[test] + fn negate_pipeline_last_cmd() { + let _g = TestGuard::new(); + // pipeline status = last cmd (false) = 1, negated → 0 + test_input("! true | false").unwrap(); + assert_eq!(state::get_status(), 0); + } + + #[test] + fn negate_pipeline_last_cmd_true() { + let _g = TestGuard::new(); + // pipeline status = last cmd (true) = 0, negated → 1 + test_input("! false | true").unwrap(); + assert_eq!(state::get_status(), 1); + } + + #[test] + fn negate_in_conjunction() { + let _g = TestGuard::new(); + // ! binds to pipeline, not conjunction: (! (true && false)) && true + test_input("! (true && false) && true").unwrap(); + assert_eq!(state::get_status(), 0); + } + + #[test] + fn negate_in_if_condition() { + let g = TestGuard::new(); + test_input("if ! false; then echo yes; fi").unwrap(); + assert_eq!(state::get_status(), 0); + assert_eq!(g.read_output(), "yes\n"); + } +} diff --git a/src/parse/lex.rs b/src/parse/lex.rs index 5e0c886..46d7820 100644 --- a/src/parse/lex.rs +++ b/src/parse/lex.rs @@ -17,9 +17,9 @@ use crate::{ }, }; -pub const KEYWORDS: [&str; 16] = [ +pub const KEYWORDS: [&str; 17] = [ "if", "then", "elif", "else", "fi", "while", "until", "select", "for", "in", "do", "done", - "case", "esac", "[[", "]]", + "case", "esac", "[[", "]]", "!" ]; pub const OPENERS: [&str; 6] = ["if", "while", "until", "for", "select", "case"]; @@ -166,6 +166,7 @@ pub enum TkRule { ErrPipe, And, Or, + Bang, Bg, Sep, Redir, @@ -882,6 +883,14 @@ impl Iterator for LexStream { return self.next(); } } + '!' if self.next_is_cmd() => { + self.cursor += 1; + let tk_type = TkRule::Bang; + + let mut tk = self.get_token((self.cursor - 1)..self.cursor, tk_type); + tk.flags |= TkFlags::KEYWORD; + tk + } '|' => { let ch_idx = self.cursor; self.cursor += 1; diff --git a/src/parse/mod.rs b/src/parse/mod.rs index 617c829..49915a7 100644 --- a/src/parse/mod.rs +++ b/src/parse/mod.rs @@ -87,6 +87,7 @@ impl ParsedSrc { Err(error) => return Err(vec![error]), } } + log::trace!("Tokens: {:#?}", tokens); let mut errors = vec![]; let mut nodes = vec![]; @@ -240,6 +241,9 @@ impl Node { } => { body.walk_tree(f); } + NdRule::Negate { ref mut cmd } => { + cmd.walk_tree(f); + } NdRule::Test { cases: _ } => (), } } @@ -630,6 +634,9 @@ pub enum NdRule { arr: Vec, body: Vec, }, + Negate { + cmd: Box, + }, CaseNode { pattern: Tk, case_blocks: Vec, @@ -784,6 +791,7 @@ impl ParseStream { try_match!(self.parse_loop()?); try_match!(self.parse_for()?); try_match!(self.parse_if()?); + try_match!(self.parse_negate()?); try_match!(self.parse_test()?); try_match!(self.parse_cmd()?); } @@ -1151,6 +1159,40 @@ impl ParseStream { .with_label(src, label) .with_context(self.context.clone()) } + fn parse_negate(&mut self) -> ShResult> { + let mut node_tks: Vec = vec![]; + + if !self.check_keyword("!") || !self.next_tk_is_some() { + return Ok(None); + } + node_tks.push(self.next_tk().unwrap()); + + let Some(cmd) = self.parse_block(true)? else { + self.panic_mode(&mut node_tks); + let span = node_tks.get_span().unwrap(); + let color = next_color(); + return Err( + self.make_err( + span.clone(), + Label::new(span) + .with_message("Expected a command after '!'") + .with_color(color), + ), + ); + }; + + node_tks.extend(cmd.tokens.clone()); + self.catch_separator(&mut node_tks); + + let node = Node { + class: NdRule::Negate { cmd: Box::new(cmd) }, + flags: NdFlags::empty(), + redirs: vec![], + context: self.context.clone(), + tokens: node_tks, + }; + Ok(Some(node)) + } fn parse_if(&mut self) -> ShResult> { // Needs at last one 'if-then', // Any number of 'elif-then', @@ -1888,6 +1930,8 @@ where name: _, ref mut body, } => check_node(body, filter, operation), + + NdRule::Negate { ref mut cmd } => check_node(cmd, filter, operation), NdRule::Test { cases: _ } => (), } } diff --git a/src/readline/history.rs b/src/readline/history.rs index 390c830..2108172 100644 --- a/src/readline/history.rs +++ b/src/readline/history.rs @@ -217,18 +217,36 @@ pub struct History { } impl History { + pub fn empty() -> Self { + Self { + path: PathBuf::new(), + pending: None, + entries: Vec::new(), + search_mask: Vec::new(), + fuzzy_finder: FuzzySelector::new("History").number_candidates(true), + no_matches: false, + cursor: 0, + //search_direction: Direction::Backward, + ignore_dups: false, + max_size: None, + } + } pub fn new() -> ShResult { let ignore_dups = crate::state::read_shopts(|s| s.core.hist_ignore_dupes); let max_hist = crate::state::read_shopts(|s| s.core.max_hist); + let path = PathBuf::from(env::var("SHEDHIST").unwrap_or({ let home = env::var("HOME").unwrap(); format!("{home}/.shed_history") })); + let mut entries = read_hist_file(&path)?; + // Enforce max_hist limit on loaded entries (negative = unlimited) if max_hist >= 0 && entries.len() > max_hist as usize { entries = entries.split_off(entries.len() - max_hist as usize); } + let search_mask = dedupe_entries(&entries); let cursor = search_mask.len(); let max_size = if max_hist < 0 { @@ -236,6 +254,7 @@ impl History { } else { Some(max_hist as u32) }; + Ok(Self { path, entries, diff --git a/src/readline/linebuf.rs b/src/readline/linebuf.rs index 30312d4..153924c 100644 --- a/src/readline/linebuf.rs +++ b/src/readline/linebuf.rs @@ -823,7 +823,7 @@ impl LineBuf { } Some(self.line_bounds(line_no)) } - pub fn this_word(&mut self, word: Word) -> (usize, usize) { + pub fn word_at(&mut self, pos: usize, word: Word) -> (usize,usize) { let start = if self.is_word_bound(self.cursor.get(), word, Direction::Backward) { self.cursor.get() } else { @@ -835,7 +835,35 @@ impl LineBuf { self.end_of_word_forward(self.cursor.get(), word) }; (start, end) + } + pub fn this_word(&mut self, word: Word) -> (usize, usize) { + self.word_at(self.cursor.get(), word) } + + pub fn number_at_cursor(&mut self) -> Option<(usize,usize)> { + self.number_at(self.cursor.get()) + } + pub fn number_at(&mut self, pos: usize) -> Option<(usize,usize)> { + // A number is a sequence of digits, possibly containing one dot, and possibly starting with a minus sign + let is_number_char = |c: &str| c == "." || c == "-" || c.chars().all(|c| c.is_ascii_digit()); + let is_digit = |gr: &str| gr.chars().all(|c| c.is_ascii_digit()); + if self.grapheme_at(pos).is_some_and(|gr| !is_number_char(gr)) { + return None; + } + let mut fwd_indices = self.directional_indices_iter_from(pos, Direction::Forward); + let mut bkwd_indices = self.directional_indices_iter_from(pos, Direction::Backward); + + // Find the digit span, then check if preceded by '-' + let mut start = bkwd_indices.find(|i| !self.grapheme_at(*i).is_some_and(is_digit)) + .map(|i| i + 1).unwrap_or(0); + let end = fwd_indices.find(|i| !self.grapheme_at(*i).is_some_and(is_digit)) + .map(|i| i - 1).unwrap_or(self.cursor.max); // inclusive end + + // Check for leading minus + if start > 0 && self.grapheme_at(start - 1) == Some("-") { start -= 1; } + + Some((start, end)) + } pub fn this_line_exclusive(&mut self) -> (usize, usize) { let line_no = self.cursor_line_number(); let (start, mut end) = self.line_bounds(line_no); @@ -947,17 +975,14 @@ impl LineBuf { dir: Direction, ) -> Box> { self.update_graphemes_lazy(); - let skip = pos + 1; + let len = self.grapheme_indices().len(); match dir { - Direction::Forward => Box::new(self.grapheme_indices().to_vec().into_iter().skip(skip)) - as Box>, - Direction::Backward => Box::new(self.grapheme_indices().to_vec().into_iter().take(pos).rev()) - as Box>, + Direction::Forward => Box::new(pos + 1..len) as Box>, + Direction::Backward => Box::new((0..pos).rev()) as Box>, } } pub fn is_word_bound(&mut self, pos: usize, word: Word, dir: Direction) -> bool { let clamped_pos = ClampedUsize::new(pos, self.cursor.max, true); - log::debug!("clamped_pos: {}", clamped_pos.get()); let cur_char = self .grapheme_at(clamped_pos.get()) .map(|c| c.to_string()) @@ -1193,20 +1218,6 @@ impl LineBuf { Bound::Around => { // End excludes the quote, so push it forward end += 1; - - // We also need to include any trailing whitespace - let end_of_line = self.end_of_line(); - let remainder = end..end_of_line; - for idx in remainder { - let Some(gr) = self.grapheme_at(idx) else { - break; - }; - if is_whitespace(gr) { - end += 1; - } else { - break; - } - } } } @@ -1793,6 +1804,11 @@ impl LineBuf { /// Find the start of the current/previous word backward pub fn start_of_word_backward(&mut self, mut pos: usize, word: Word) -> usize { let default = 0; + // In insert mode, cursor can be one past the last grapheme; step back so + // grapheme_at(pos) doesn't return None and bail to 0 + if pos > 0 && self.grapheme_at(pos).is_none() { + pos -= 1; + } let mut indices_iter = (0..pos).rev().peekable(); match word { @@ -2000,10 +2016,12 @@ impl LineBuf { return; } let start = self.index_byte_pos(pos); - let end = start + gr.len(); + let end = start + (new.len().max(gr.len())); self.buffer.replace_range(start..end, new); } pub fn calc_indent_level(&mut self) { + // FIXME: This implementation is extremely naive but it kind of sort of works for now + // Need to re-implement it and write tests let to_cursor = self .slice_to_cursor() .map(|s| s.to_string()) @@ -2377,15 +2395,28 @@ impl LineBuf { MotionCmd(_count, Motion::WholeBuffer) => { MotionKind::Exclusive((0, self.grapheme_indices().len())) } - MotionCmd(_count, Motion::BeginningOfBuffer) => MotionKind::On(0), - MotionCmd(_count, Motion::EndOfBuffer) => { - if self.cursor.exclusive { - MotionKind::On(self.grapheme_indices().len().saturating_sub(1)) - } else { - MotionKind::On(self.grapheme_indices().len()) - } + MotionCmd(_count, Motion::StartOfBuffer) => { + MotionKind::InclusiveWithTargetCol((0, self.end_of_line()), 0) } - MotionCmd(_count, Motion::ToColumn) => todo!(), + MotionCmd(_count, Motion::EndOfBuffer) => { + let end = self.grapheme_indices().len(); + MotionKind::InclusiveWithTargetCol((self.start_of_line(), end), 0) + } + MotionCmd(count, Motion::ToColumn) => { + let s = self.start_of_line(); + let mut end = s; + for _ in 0..count { + let Some(gr) = self.grapheme_at(end) else { + end = self.grapheme_indices().len(); + break; + }; + if gr == "\n" { + break; + } + end += 1; + } + MotionKind::On(end.saturating_sub(1)) // count starts at 1, columns are "zero-indexed", so we subtract one + } MotionCmd(count, Motion::Range(start, end)) => { let mut final_end = end; if self.cursor.exclusive { @@ -2607,8 +2638,8 @@ impl LineBuf { MotionKind::InclusiveWithTargetCol(..) | MotionKind::ExclusiveWithTargetCol(..) ) || matches!(self.select_mode, Some(SelectMode::Line(_))); let register_content = if is_linewise { - if !text.ends_with('\n') && !text.is_empty() { - text.push('\n'); + if text.ends_with('\n') && !text.is_empty() { + text = text.strip_suffix('\n').unwrap().to_string(); } RegisterContent::Line(text) } else { @@ -2645,19 +2676,27 @@ impl LineBuf { self.apply_motion(motion); } Verb::ReplaceCharInplace(ch, count) => { - for i in 0..count { - let mut buf = [0u8; 4]; - let new = ch.encode_utf8(&mut buf); - self.replace_at_cursor(new); + if let Some((start,end)) = self.select_range() { + let end = (end + 1).min(self.grapheme_indices().len()); // inclusive + let replaced = ch.to_string().repeat(end.saturating_sub(start)); + self.replace_at(start, &replaced); + self.cursor.set(start); + } else { + for i in 0..count { + let mut buf = [0u8; 4]; + let new = ch.encode_utf8(&mut buf); + self.replace_at_cursor(new); - // try to increment the cursor until we are on the last iteration - // or until we hit the end of the buffer - if i != count.saturating_sub(1) && !self.cursor.inc() { - break; - } - } + // try to increment the cursor until we are on the last iteration + // or until we hit the end of the buffer + if i != count.saturating_sub(1) && !self.cursor.inc() { + break; + } + } + } } Verb::ToggleCaseInplace(count) => { + let mut did_something = false; for i in 0..count { let Some(gr) = self.grapheme_at_cursor() else { return Ok(()); @@ -2679,10 +2718,14 @@ impl LineBuf { // try to increment the cursor until we are on the last iteration // or until we hit the end of the buffer + did_something = true; if i != count.saturating_sub(1) && !self.cursor.inc() { break; } } + if did_something { + self.cursor.inc(); + } } Verb::ToggleCaseRange => { let Some((start, end)) = self.range_from_motion(&motion) else { @@ -2707,6 +2750,7 @@ impl LineBuf { }; self.replace_at(i, new); } + self.cursor.set(start); } Verb::ToLower => { let Some((start, end)) = self.range_from_motion(&motion) else { @@ -2731,6 +2775,7 @@ impl LineBuf { }; self.replace_at(i, new); } + self.cursor.set(start); } Verb::ToUpper => { let Some((start, end)) = self.range_from_motion(&motion) else { @@ -2755,6 +2800,7 @@ impl LineBuf { }; self.replace_at(i, new); } + self.cursor.set(start); } Verb::Redo | Verb::Undo => { let (edit_provider, edit_receiver) = match verb { @@ -2810,33 +2856,50 @@ impl LineBuf { } match content { RegisterContent::Span(ref text) => { - let insert_idx = match anchor { - Anchor::After => self - .cursor - .get() - .saturating_add(1) - .min(self.grapheme_indices().len()), - Anchor::Before => self.cursor.get(), + match anchor { + Anchor::After => { + let insert_idx = self + .cursor + .get() + .saturating_add(1) + .min(self.grapheme_indices().len()); + let offset = text.len().max(1); + + self.insert_str_at(insert_idx, text); + self.cursor.add(offset); + }, + Anchor::Before => { + let insert_idx = self.cursor.get(); + self.insert_str_at(insert_idx, text); + self.cursor.add(text.len().saturating_sub(1)); + }, }; - self.insert_str_at(insert_idx, text); - self.cursor.add(text.len().saturating_sub(1)); } RegisterContent::Line(ref text) => { let insert_idx = match anchor { Anchor::After => self.end_of_line(), Anchor::Before => self.start_of_line(), }; - let needs_newline = self - .grapheme_before(insert_idx) - .is_some_and(|gr| gr != "\n"); - if needs_newline { - let full = format!("\n{}", text); - self.insert_str_at(insert_idx, &full); - self.cursor.set(insert_idx + 1); - } else { - self.insert_str_at(insert_idx, text); - self.cursor.set(insert_idx); - } + let mut full = text.to_string(); + let mut offset = 0; + + match anchor { + Anchor::After => { + if self.grapheme_before(insert_idx).is_none_or(|gr| gr != "\n") { + full = format!("\n{text}"); + offset += 1; + } + if self.grapheme_at(insert_idx).is_some_and(|gr| gr != "\n") { + full = format!("{full}\n"); + } + } + Anchor::Before => { + full = format!("{full}\n"); + } + } + + self.insert_str_at(insert_idx, &full); + self.cursor.set(insert_idx + offset); } RegisterContent::Empty => {} } @@ -2872,6 +2935,7 @@ impl LineBuf { self.force_replace_at(i, " "); } last_was_whitespace = false; + self.cursor.set(i); continue; } last_was_whitespace = is_whitespace(gr); @@ -2907,8 +2971,6 @@ impl LineBuf { Verb::Insert(string) => { self.insert_str_at_cursor(&string); let graphemes = string.graphemes(true).count(); - log::debug!("Inserted string: {string:?}, graphemes: {graphemes}"); - log::debug!("buffer after insert: {:?}", self.buffer); self.cursor.add(graphemes); } Verb::Indent => { @@ -3067,7 +3129,13 @@ impl LineBuf { } else { -(n as i64) }; - let (s, e) = self.select_range().unwrap_or(self.this_word(Word::Normal)); + let (s, e) = if let Some(r) = self.select_range() { + r + } else if let Some(r) = self.number_at_cursor() { + r + } else { + return Ok(()); + }; let end = if self.select_range().is_some() { if e < self.grapheme_indices().len() - 1 { e @@ -3122,9 +3190,21 @@ impl LineBuf { } else if let Ok(num) = word.parse::() { let width = word.len(); let new_num = num + inc; + let num_fmt = if new_num < 0 { + let abs = new_num.unsigned_abs(); + let digit_width = if num < 0 { width - 1 } else { width }; + format!("-{abs:0>digit_width$}") + } else if num < 0 { + // Was negative, now positive — pad to width-1 since + // the minus sign is gone (e.g. -001 + 2 = 00001) + let digit_width = width - 1; + format!("{new_num:0>digit_width$}") + } else { + format!("{new_num:0>width$}") + }; self .buffer - .replace_range(byte_start..byte_end, &format!("{new_num:0>width$}")); + .replace_range(byte_start..byte_end, &num_fmt); self.update_graphemes(); self.cursor.set(s); } @@ -3144,7 +3224,6 @@ impl LineBuf { | Verb::VisualModeSelectLast => self.apply_motion(motion), // Already handled logic for these Verb::ShellCmd(cmd) => { - log::debug!("Executing ex-mode command from widget: {cmd}"); let mut vars = HashSet::new(); vars.insert("_BUFFER".into()); vars.insert("_CURSOR".into()); @@ -3187,17 +3266,10 @@ impl LineBuf { self.update_graphemes(); self.cursor.set_max(self.buffer.graphemes(true).count()); self.cursor.set(cursor); - log::debug!( - "[ShellCmd] post-widget: cursor={}, anchor={}, select_range={:?}", - cursor, - anchor, - self.select_range - ); if anchor != cursor && self.select_range.is_some() { self.select_range = Some(ordered(cursor, anchor)); } if !keys.is_empty() { - log::debug!("Pending widget keys from shell command: {keys}"); write_meta(|m| m.set_pending_widget_keys(&keys)) } } diff --git a/src/readline/mod.rs b/src/readline/mod.rs index ef8e9ca..c4b8a7e 100644 --- a/src/readline/mod.rs +++ b/src/readline/mod.rs @@ -39,6 +39,9 @@ pub mod term; pub mod vicmd; pub mod vimode; +#[cfg(test)] +pub mod tests; + pub mod markers { use super::Marker; @@ -289,6 +292,37 @@ impl ShedVi { Ok(new) } + pub fn new_no_hist(prompt: Prompt, tty: RawFd) -> ShResult { + let mut new = Self { + reader: PollReader::new(), + writer: TermWriter::new(tty), + prompt, + completer: Box::new(FuzzyCompleter::default()), + highlighter: Highlighter::new(), + mode: Box::new(ViInsert::new()), + next_is_escaped: false, + saved_mode: None, + pending_keymap: Vec::new(), + old_layout: None, + repeat_action: None, + repeat_motion: None, + editor: LineBuf::new(), + history: History::empty(), + needs_redraw: true, + }; + write_vars(|v| { + v.set_var( + "SHED_VI_MODE", + VarKind::Str(new.mode.report_mode().to_string()), + VarFlags::NONE, + ) + })?; + new.prompt.refresh(); + new.writer.flush_write("\n")?; // ensure we start on a new line, in case the previous command didn't end with a newline + new.print_line(false)?; + Ok(new) + } + pub fn with_initial(mut self, initial: &str) -> Self { self.editor = LineBuf::new().with_initial(initial, 0); self @@ -696,7 +730,8 @@ impl ShedVi { self.needs_redraw = true; return Ok(None); - } else if let KeyEvent(KeyCode::Char('R'), ModKeys::CTRL) = key { + } else if let KeyEvent(KeyCode::Char('R'), ModKeys::CTRL) = key + && self.mode.report_mode() == ModeReport::Insert { let initial = self.editor.as_str(); match self.history.start_search(initial) { Some(entry) => { @@ -814,7 +849,7 @@ impl ShedVi { let has_edit_verb = cmd.verb().is_some_and(|v| v.1.is_edit()); let before = self.editor.buffer.clone(); - self.exec_cmd(cmd)?; + self.exec_cmd(cmd, false)?; if let Some(keys) = write_meta(|m| m.take_pending_widget_keys()) { for key in keys { self.handle_key(key)?; @@ -1072,99 +1107,70 @@ impl ShedVi { post_mode_change.exec(); } - pub fn exec_cmd(&mut self, mut cmd: ViCmd) -> ShResult<()> { + fn exec_mode_transition(&mut self, cmd: ViCmd, from_replay: bool) -> ShResult<()> { let mut select_mode = None; let mut is_insert_mode = false; - if cmd.is_mode_transition() { - let count = cmd.verb_count(); + let count = cmd.verb_count(); - let mut mode: Box = if matches!( - self.mode.report_mode(), - ModeReport::Ex | ModeReport::Verbatim - ) && cmd.flags.contains(CmdFlags::EXIT_CUR_MODE) - { - if let Some(saved) = self.saved_mode.take() { - saved - } else { - Box::new(ViNormal::new()) + let mut mode: Box = if matches!( + self.mode.report_mode(), + ModeReport::Ex | ModeReport::Verbatim + ) && cmd.flags.contains(CmdFlags::EXIT_CUR_MODE) + { + if let Some(saved) = self.saved_mode.take() { + saved + } else { + Box::new(ViNormal::new()) + } + } else { + match cmd.verb().unwrap().1 { + Verb::Change | Verb::InsertModeLineBreak(_) | Verb::InsertMode => { + is_insert_mode = true; + Box::new(ViInsert::new().with_count(count as u16).record_cmd(cmd.clone())) } - } else { - match cmd.verb().unwrap().1 { - Verb::Change | Verb::InsertModeLineBreak(_) | Verb::InsertMode => { - is_insert_mode = true; - Box::new(ViInsert::new().with_count(count as u16)) - } - Verb::ExMode => Box::new(ViEx::new()), + Verb::ExMode => Box::new(ViEx::new()), - Verb::VerbatimMode => Box::new(ViVerbatim::read_one().with_count(count as u16)), - - Verb::NormalMode => Box::new(ViNormal::new()), - - Verb::ReplaceMode => Box::new(ViReplace::new()), - - Verb::VisualModeSelectLast => { - if self.mode.report_mode() != ModeReport::Visual { - self - .editor - .start_selecting(SelectMode::Char(SelectAnchor::End)); - } - let mut mode: Box = Box::new(ViVisual::new()); - self.swap_mode(&mut mode); - - return self.editor.exec_cmd(cmd); - } - Verb::VisualMode => { - select_mode = Some(SelectMode::Char(SelectAnchor::End)); - Box::new(ViVisual::new()) - } - Verb::VisualModeLine => { - select_mode = Some(SelectMode::Line(SelectAnchor::End)); - Box::new(ViVisual::new()) - } - - _ => unreachable!(), + Verb::VerbatimMode => { + self.reader.verbatim_single = true; + Box::new(ViVerbatim::new().with_count(count as u16)) } - }; - self.swap_mode(&mut mode); + Verb::NormalMode => Box::new(ViNormal::new()), - if matches!( - self.mode.report_mode(), - ModeReport::Ex | ModeReport::Verbatim - ) { - self.saved_mode = Some(mode); - write_vars(|v| { - v.set_var( - "SHED_VI_MODE", - VarKind::Str(self.mode.report_mode().to_string()), - VarFlags::NONE, - ) - })?; - self.prompt.refresh(); - return Ok(()); + Verb::ReplaceMode => Box::new(ViReplace::new()), + + Verb::VisualModeSelectLast => { + if self.mode.report_mode() != ModeReport::Visual { + self + .editor + .start_selecting(SelectMode::Char(SelectAnchor::End)); + } + let mut mode: Box = Box::new(ViVisual::new()); + self.swap_mode(&mut mode); + + return self.editor.exec_cmd(cmd); + } + Verb::VisualMode => { + select_mode = Some(SelectMode::Char(SelectAnchor::End)); + Box::new(ViVisual::new()) + } + Verb::VisualModeLine => { + select_mode = Some(SelectMode::Line(SelectAnchor::End)); + Box::new(ViVisual::new()) + } + + _ => unreachable!(), } + }; - if mode.is_repeatable() { - self.repeat_action = mode.as_replay(); - } - - // Set cursor clamp BEFORE executing the command so that motions - // (like EndOfLine for 'A') can reach positions valid in the new mode - self.editor.set_cursor_clamp(self.mode.clamp_cursor()); - self.editor.exec_cmd(cmd)?; - - if let Some(sel_mode) = select_mode { - self.editor.start_selecting(sel_mode); - } else { - self.editor.stop_selecting(); - } - if is_insert_mode { - self.editor.mark_insert_mode_start_pos(); - } else { - self.editor.clear_insert_mode_start_pos(); - } + self.swap_mode(&mut mode); + if matches!( + self.mode.report_mode(), + ModeReport::Ex | ModeReport::Verbatim + ) { + self.saved_mode = Some(mode); write_vars(|v| { v.set_var( "SHED_VI_MODE", @@ -1173,8 +1179,56 @@ impl ShedVi { ) })?; self.prompt.refresh(); - return Ok(()); + } + + if mode.is_repeatable() && !from_replay { + self.repeat_action = mode.as_replay(); + } + + // Set cursor clamp BEFORE executing the command so that motions + // (like EndOfLine for 'A') can reach positions valid in the new mode + self.editor.set_cursor_clamp(self.mode.clamp_cursor()); + self.editor.exec_cmd(cmd)?; + + if let Some(sel_mode) = select_mode { + self.editor.start_selecting(sel_mode); + } else { + self.editor.stop_selecting(); + } + if is_insert_mode { + self.editor.mark_insert_mode_start_pos(); + } else { + self.editor.clear_insert_mode_start_pos(); + } + + write_vars(|v| { + v.set_var( + "SHED_VI_MODE", + VarKind::Str(self.mode.report_mode().to_string()), + VarFlags::NONE, + ) + })?; + self.prompt.refresh(); + + Ok(()) + } + + pub fn clone_mode(&self) -> Box { + match self.mode.report_mode() { + ModeReport::Normal => Box::new(ViNormal::new()), + ModeReport::Insert => Box::new(ViInsert::new()), + ModeReport::Visual => Box::new(ViVisual::new()), + ModeReport::Ex => Box::new(ViEx::new()), + ModeReport::Replace => Box::new(ViReplace::new()), + ModeReport::Verbatim => Box::new(ViVerbatim::new()), + ModeReport::Unknown => unreachable!(), + } + } + + pub fn exec_cmd(&mut self, mut cmd: ViCmd, from_replay: bool) -> ShResult<()> { + if cmd.is_mode_transition() { + return self.exec_mode_transition(cmd, from_replay); } else if cmd.is_cmd_repeat() { let Some(replay) = self.repeat_action.clone() else { return Ok(()); @@ -1186,11 +1240,36 @@ impl ShedVi { if count > 1 { repeat = count as u16; } + + let old_mode = self.mode.report_mode(); + for _ in 0..repeat { let cmds = cmds.clone(); - for cmd in cmds { - self.editor.exec_cmd(cmd)? + for (i, cmd) in cmds.iter().enumerate() { + log::debug!("Replaying command {cmd:?} in mode {:?}, replay {i}/{repeat}", self.mode.report_mode()); + self.exec_cmd(cmd.clone(), true)?; + // After the first command, start merging so all subsequent + // edits fold into one undo entry (e.g. cw + inserted chars) + if i == 0 + && let Some(edit) = self.editor.undo_stack.last_mut() { + edit.start_merge(); + } } + // Stop merging at the end of the replay + if let Some(edit) = self.editor.undo_stack.last_mut() { + edit.stop_merge(); + } + + let old_mode_clone = match old_mode { + ModeReport::Normal => Box::new(ViNormal::new()) as Box, + ModeReport::Insert => Box::new(ViInsert::new()) as Box, + ModeReport::Visual => Box::new(ViVisual::new()) as Box, + ModeReport::Ex => Box::new(ViEx::new()) as Box, + ModeReport::Replace => Box::new(ViReplace::new()) as Box, + ModeReport::Verbatim => Box::new(ViVerbatim::new()) as Box, + ModeReport::Unknown => unreachable!(), + }; + self.mode = old_mode_clone; } } CmdReplay::Single(mut cmd) => { @@ -1253,7 +1332,7 @@ impl ShedVi { self.swap_mode(&mut mode); } - if cmd.is_repeatable() { + if cmd.is_repeatable() && !from_replay { if self.mode.report_mode() == ModeReport::Visual { // The motion is assigned in the line buffer execution, so we also have to // assign it here in order to be able to repeat it @@ -1272,7 +1351,7 @@ impl ShedVi { self.editor.exec_cmd(cmd.clone())?; - if self.mode.report_mode() == ModeReport::Visual && cmd.verb().is_some_and(|v| v.1.is_edit()) { + if self.mode.report_mode() == ModeReport::Visual && cmd.verb().is_some_and(|v| v.1.is_edit() || v.1 == Verb::Yank) { self.editor.stop_selecting(); let mut mode: Box = Box::new(ViNormal::new()); self.swap_mode(&mut mode); @@ -1421,6 +1500,7 @@ pub fn get_insertions(input: &str) -> Vec<(usize, Marker)> { pub fn marker_for(class: &TkRule) -> Option { match class { TkRule::Pipe + | TkRule::Bang | TkRule::ErrPipe | TkRule::And | TkRule::Or diff --git a/src/readline/register.rs b/src/readline/register.rs index 1fd8581..6fffcbf 100644 --- a/src/readline/register.rs +++ b/src/readline/register.rs @@ -2,6 +2,24 @@ use std::{fmt::Display, sync::Mutex}; pub static REGISTERS: Mutex = Mutex::new(Registers::new()); +#[cfg(test)] +pub static SAVED_REGISTERS: Mutex> = Mutex::new(None); + +#[cfg(test)] +pub fn save_registers() { + let mut saved = SAVED_REGISTERS.lock().unwrap(); + *saved = Some(REGISTERS.lock().unwrap().clone()); +} + +#[cfg(test)] +pub fn restore_registers() { + let mut saved = SAVED_REGISTERS.lock().unwrap(); + if let Some(ref registers) = *saved { + *REGISTERS.lock().unwrap() = registers.clone(); + } + *saved = None; +} + pub fn read_register(ch: Option) -> Option { let lock = REGISTERS.lock().unwrap(); lock.get_reg(ch).map(|r| r.content().clone()) @@ -79,7 +97,7 @@ impl RegisterContent { } } -#[derive(Default, Debug)] +#[derive(Default, Clone, Debug)] pub struct Registers { default: Register, a: Register, diff --git a/src/readline/term.rs b/src/readline/term.rs index c0216a5..66865af 100644 --- a/src/readline/term.rs +++ b/src/readline/term.rs @@ -499,6 +499,7 @@ pub struct PollReader { parser: Parser, collector: KeyCollector, byte_buf: VecDeque, + pub verbatim_single: bool, pub verbatim: bool, } @@ -508,6 +509,7 @@ impl PollReader { parser: Parser::new(), collector: KeyCollector::new(), byte_buf: VecDeque::new(), + verbatim_single: false, verbatim: false, } } @@ -531,6 +533,15 @@ impl PollReader { None } + pub fn read_one_verbatim(&mut self) -> Option { + if self.byte_buf.is_empty() { + return None; + } + let bytes: Vec = self.byte_buf.drain(..).collect(); + let verbatim_str = String::from_utf8_lossy(&bytes).to_string(); + Some(KeyEvent(KeyCode::Verbatim(verbatim_str.into()), ModKeys::empty())) + } + pub fn feed_bytes(&mut self, bytes: &[u8]) { self.byte_buf.extend(bytes); } @@ -544,17 +555,28 @@ impl Default for PollReader { impl KeyReader for PollReader { fn read_key(&mut self) -> Result, ShErr> { + if self.verbatim_single { + if let Some(key) = self.read_one_verbatim() { + self.verbatim_single = false; + return Ok(Some(key)); + } + return Ok(None); + } if self.verbatim { if let Some(paste) = self.handle_bracket_paste() { return Ok(Some(paste)); } // If we're in verbatim mode but haven't seen the end marker yet, don't attempt to parse keys return Ok(None); - } else if self.byte_buf.len() == 1 - && self.byte_buf.front() == Some(&b'\x1b') { - // User pressed escape - self.byte_buf.pop_front(); // Consume the escape byte - return Ok(Some(KeyEvent(KeyCode::Esc, ModKeys::empty()))); + } else if self.byte_buf.front() == Some(&b'\x1b') { + // Escape: if it's the only byte, or the next byte isn't a valid + // escape sequence prefix ([ or O), emit a standalone Escape + if self.byte_buf.len() == 1 + || !matches!(self.byte_buf.get(1), Some(b'[') | Some(b'O')) + { + self.byte_buf.pop_front(); + return Ok(Some(KeyEvent(KeyCode::Esc, ModKeys::empty()))); + } } while let Some(byte) = self.byte_buf.pop_front() { self.parser.advance(&mut self.collector, &[byte]); diff --git a/src/readline/tests.rs b/src/readline/tests.rs new file mode 100644 index 0000000..fa19d02 --- /dev/null +++ b/src/readline/tests.rs @@ -0,0 +1,229 @@ +#![allow(non_snake_case)] +use std::os::fd::AsRawFd; + +use crate::{readline::{Prompt, ShedVi}, testutil::TestGuard}; + +/// Tests for our vim logic emulation. Each test consists of an initial text, a sequence of keys to feed, and the expected final text and cursor position. +macro_rules! vi_test { + { $($name:ident: $input:expr => $op:expr => $expected_text:expr,$expected_cursor:expr);* } => { + $( + #[test] + fn $name() { + let (mut vi, _g) = test_vi($input); + vi.feed_bytes(b"\x1b"); // Start in normal mode + vi.process_input().unwrap(); + + vi.feed_bytes($op.as_bytes()); + vi.process_input().unwrap(); + assert_eq!(vi.editor.as_str(), $expected_text); + assert_eq!(vi.editor.cursor.get(), $expected_cursor); + } + )* + + }; +} + +fn test_vi(initial: &str) -> (ShedVi, TestGuard) { + let g = TestGuard::new(); + let prompt = Prompt::default(); + let vi = ShedVi::new_no_hist(prompt, g.pty_slave().as_raw_fd()) + .unwrap() + .with_initial(initial); + + (vi, g) +} + +// Why can't I marry a programming language +vi_test! { + vi_dw_basic : "hello world" => "dw" => "world", 0; + vi_dw_middle : "one two three" => "wdw" => "one three", 4; + vi_dd_whole_line : "hello world" => "dd" => "", 0; + vi_x_single : "hello" => "x" => "ello", 0; + vi_x_middle : "hello" => "llx" => "helo", 2; + vi_X_backdelete : "hello" => "llX" => "hllo", 1; + vi_h_motion : "hello" => "$h" => "hello", 3; + vi_l_motion : "hello" => "l" => "hello", 1; + vi_h_at_start : "hello" => "h" => "hello", 0; + vi_l_at_end : "hello" => "$l" => "hello", 4; + vi_w_forward : "one two three" => "w" => "one two three", 4; + vi_b_backward : "one two three" => "$b" => "one two three", 8; + vi_e_end : "one two three" => "e" => "one two three", 2; + vi_ge_back_end : "one two three" => "$ge" => "one two three", 6; + vi_w_punctuation : "foo.bar baz" => "w" => "foo.bar baz", 3; + vi_e_punctuation : "foo.bar baz" => "e" => "foo.bar baz", 2; + vi_b_punctuation : "foo.bar baz" => "$b" => "foo.bar baz", 8; + vi_w_at_eol : "hello" => "$w" => "hello", 4; + vi_b_at_bol : "hello" => "b" => "hello", 0; + vi_W_forward : "foo.bar baz" => "W" => "foo.bar baz", 8; + vi_B_backward : "foo.bar baz" => "$B" => "foo.bar baz", 8; + vi_E_end : "foo.bar baz" => "E" => "foo.bar baz", 6; + vi_gE_back_end : "one two three" => "$gE" => "one two three", 6; + vi_W_skip_punct : "one-two three" => "W" => "one-two three", 8; + vi_B_skip_punct : "one two-three" => "$B" => "one two-three", 4; + vi_E_skip_punct : "one-two three" => "E" => "one-two three", 6; + vi_dW_big : "foo.bar baz" => "dW" => "baz", 0; + vi_cW_big : "foo.bar baz" => "cWx\x1b" => "x baz", 0; + vi_zero_bol : " hello" => "$0" => " hello", 0; + vi_caret_first_char : " hello" => "$^" => " hello", 2; + vi_dollar_eol : "hello world" => "$" => "hello world", 10; + vi_g_last_nonws : "hello " => "g_" => "hello ", 4; + vi_g_no_trailing : "hello" => "g_" => "hello", 4; + vi_pipe_column : "hello world" => "6|" => "hello world", 5; + vi_pipe_col1 : "hello world" => "1|" => "hello world", 0; + vi_I_insert_front : " hello" => "Iworld \x1b" => " world hello", 7; + vi_A_append_end : "hello" => "A world\x1b" => "hello world", 10; + vi_f_find : "hello world" => "fo" => "hello world", 4; + vi_F_find_back : "hello world" => "$Fo" => "hello world", 7; + vi_t_till : "hello world" => "tw" => "hello world", 5; + vi_T_till_back : "hello world" => "$To" => "hello world", 8; + vi_f_no_match : "hello" => "fz" => "hello", 0; + vi_semicolon_repeat : "abcabc" => "fa;;" => "abcabc", 3; + vi_comma_reverse : "abcabc" => "fa;;," => "abcabc", 0; + vi_df_semicolon : "abcabc" => "fa;;dfa" => "abcabc", 3; + vi_t_at_target : "aab" => "lta" => "aab", 1; + vi_D_to_end : "hello world" => "wD" => "hello ", 5; + vi_d_dollar : "hello world" => "wd$" => "hello ", 5; + vi_d0_to_start : "hello world" => "$d0" => "d", 0; + vi_dw_multiple : "one two three" => "d2w" => "three", 0; + vi_dt_char : "hello world" => "dtw" => "world", 0; + vi_df_char : "hello world" => "dfw" => "orld", 0; + vi_dh_back : "hello" => "lldh" => "hllo", 1; + vi_dl_forward : "hello" => "dl" => "ello", 0; + vi_dge_back_end : "one two three" => "$dge" => "one tw", 5; + vi_dG_to_end : "hello world" => "dG" => "", 0; + vi_dgg_to_start : "hello world" => "$dgg" => "", 0; + vi_d_semicolon : "abcabc" => "fad;" => "abcabc", 3; + vi_cw_basic : "hello world" => "cwfoo\x1b" => "foo world", 2; + vi_C_to_end : "hello world" => "wCfoo\x1b" => "hello foo", 8; + vi_cc_whole : "hello world" => "ccfoo\x1b" => "foo", 2; + vi_ct_char : "hello world" => "ctwfoo\x1b" => "fooworld", 2; + vi_s_single : "hello" => "sfoo\x1b" => "fooello", 2; + vi_S_whole_line : "hello world" => "Sfoo\x1b" => "foo", 2; + vi_cl_forward : "hello" => "clX\x1b" => "Xello", 0; + vi_ch_backward : "hello" => "llchX\x1b" => "hXllo", 1; + vi_cb_word_back : "hello world" => "$cbfoo\x1b" => "hello food", 8; + vi_ce_word_end : "hello world" => "cefoo\x1b" => "foo world", 2; + vi_c0_to_start : "hello world" => "wc0foo\x1b" => "fooworld", 2; + vi_yw_p_basic : "hello world" => "ywwP" => "hello hello world", 11; + vi_dw_p_paste : "hello world" => "dwP" => "hello world", 5; + vi_dd_p_paste : "hello world" => "ddp" => "\nhello world", 1; + vi_y_dollar_p : "hello world" => "wy$P" => "hello worldworld", 10; + vi_ye_p : "hello world" => "yewP" => "hello helloworld", 10; + vi_yy_p : "hello world" => "yyp" => "hello world\nhello world", 12; + vi_Y_p : "hello world" => "Yp" => "hhello worldello world", 11; + vi_p_after_x : "hello" => "xp" => "ehllo", 1; + vi_P_before : "hello" => "llxP" => "hello", 2; + vi_paste_empty : "hello" => "p" => "hello", 0; + vi_r_replace : "hello" => "ra" => "aello", 0; + vi_r_middle : "hello" => "llra" => "healo", 2; + vi_r_at_end : "hello" => "$ra" => "hella", 4; + vi_r_space : "hello" => "r " => " ello", 0; + vi_r_with_count : "hello" => "3rx" => "xxxlo", 2; + vi_tilde_single : "hello" => "~" => "Hello", 1; + vi_tilde_count : "hello" => "3~" => "HELlo", 3; + vi_tilde_at_end : "HELLO" => "$~" => "HELLo", 4; + vi_tilde_mixed : "hElLo" => "5~" => "HeLlO", 4; + vi_gu_word : "HELLO world" => "guw" => "hello world", 0; + vi_gU_word : "hello WORLD" => "gUw" => "HELLO WORLD", 0; + vi_gu_dollar : "HELLO WORLD" => "gu$" => "hello world", 0; + vi_gU_dollar : "hello world" => "gU$" => "HELLO WORLD", 0; + vi_gu_0 : "HELLO WORLD" => "$gu0" => "hello worlD", 0; + vi_gU_0 : "hello world" => "$gU0" => "HELLO WORLd", 0; + vi_gtilde_word : "hello WORLD" => "g~w" => "HELLO WORLD", 0; + vi_gtilde_dollar : "hello WORLD" => "g~$" => "HELLO world", 0; + vi_diw_inner : "one two three" => "wdiw" => "one three", 4; + vi_ciw_replace : "hello world" => "ciwfoo\x1b" => "foo world", 2; + vi_daw_around : "one two three" => "wdaw" => "one three", 4; + vi_yiw_p : "hello world" => "yiwAp \x1bp" => "hello worldp hello", 17; + vi_diW_big_inner : "one-two three" => "diW" => " three", 0; + vi_daW_big_around : "one two-three end" => "wdaW" => "one end", 4; + vi_ciW_big : "one-two three" => "ciWx\x1b" => "x three", 0; + vi_di_dquote : "one \"two\" three" => "f\"di\"" => "one \"\" three", 5; + vi_da_dquote : "one \"two\" three" => "f\"da\"" => "one three", 4; + vi_ci_dquote : "one \"two\" three" => "f\"ci\"x\x1b" => "one \"x\" three", 5; + vi_di_squote : "one 'two' three" => "f'di'" => "one '' three", 5; + vi_da_squote : "one 'two' three" => "f'da'" => "one three", 4; + vi_di_backtick : "one `two` three" => "f`di`" => "one `` three", 5; + vi_da_backtick : "one `two` three" => "f`da`" => "one three", 4; + vi_ci_dquote_empty : "one \"\" three" => "f\"ci\"x\x1b" => "one \"x\" three", 5; + vi_di_paren : "one (two) three" => "f(di(" => "one () three", 5; + vi_da_paren : "one (two) three" => "f(da(" => "one three", 4; + vi_ci_paren : "one (two) three" => "f(ci(x\x1b" => "one (x) three", 5; + vi_di_brace : "one {two} three" => "f{di{" => "one {} three", 5; + vi_da_brace : "one {two} three" => "f{da{" => "one three", 4; + vi_di_bracket : "one [two] three" => "f[di[" => "one [] three", 5; + vi_da_bracket : "one [two] three" => "f[da[" => "one three", 4; + vi_di_angle : "one three" => "f "one <> three", 5; + vi_da_angle : "one three" => "f "one three", 4; + vi_di_paren_nested : "fn(a, (b, c))" => "f(di(" => "fn()", 3; + vi_di_paren_empty : "fn() end" => "f(di(" => "fn() end", 3; + vi_dib_alias : "one (two) three" => "f(dib" => "one () three", 5; + vi_diB_alias : "one {two} three" => "f{diB" => "one {} three", 5; + vi_percent_paren : "(hello) world" => "%" => "(hello) world", 6; + vi_percent_brace : "{hello} world" => "%" => "{hello} world", 6; + vi_percent_bracket : "[hello] world" => "%" => "[hello] world", 6; + vi_percent_from_close: "(hello) world" => "f)%" => "(hello) world", 0; + vi_d_percent_paren : "(hello) world" => "d%" => " world", 0; + vi_i_insert : "hello" => "iX\x1b" => "Xhello", 0; + vi_a_append : "hello" => "aX\x1b" => "hXello", 1; + vi_I_front : " hello" => "IX\x1b" => " Xhello", 2; + vi_A_end : "hello" => "AX\x1b" => "helloX", 5; + vi_o_open_below : "hello" => "oworld\x1b" => "hello\nworld", 10; + vi_O_open_above : "hello" => "Oworld\x1b" => "world\nhello", 4; + vi_empty_input : "" => "i hello\x1b" => " hello", 5; + vi_insert_escape : "hello" => "aX\x1b" => "hXello", 1; + vi_ctrl_w_del_word : "hello world" => "A\x17\x1b" => "hello ", 5; + vi_ctrl_h_backspace : "hello" => "A\x08\x1b" => "hell", 3; + vi_u_undo_delete : "hello world" => "dwu" => "hello world", 0; + vi_u_undo_change : "hello world" => "ciwfoo\x1bu" => "hello world", 0; + vi_u_undo_x : "hello" => "xu" => "hello", 0; + vi_ctrl_r_redo : "hello" => "xu\x12" => "ello", 0; + vi_u_multiple : "hello world" => "xdwu" => "ello world", 0; + vi_redo_after_undo : "hello world" => "dwu\x12" => "world", 0; + vi_dot_repeat_x : "hello" => "x." => "llo", 0; + vi_dot_repeat_dw : "one two three" => "dw." => "three", 0; + vi_dot_repeat_cw : "one two three" => "cwfoo\x1bw." => "foo foo three", 6; + vi_dot_repeat_r : "hello" => "ra.." => "aello", 0; + vi_dot_repeat_s : "hello" => "sX\x1bl." => "XXllo", 1; + vi_count_h : "hello world" => "$3h" => "hello world", 7; + vi_count_l : "hello world" => "3l" => "hello world", 3; + vi_count_w : "one two three four" => "2w" => "one two three four", 8; + vi_count_b : "one two three four" => "$2b" => "one two three four", 8; + vi_count_x : "hello" => "3x" => "lo", 0; + vi_count_dw : "one two three four" => "2dw" => "three four", 0; + vi_verb_count_motion : "one two three four" => "d2w" => "three four", 0; + vi_count_s : "hello" => "3sX\x1b" => "Xlo", 0; + vi_indent_line : "hello" => ">>" => "\thello", 0; + vi_dedent_line : "\thello" => "<<" => "hello", 0; + vi_indent_double : "hello" => ">>>>" => "\t\thello", 0; + vi_J_join_lines : "hello\nworld" => "J" => "hello world", 5; + vi_v_u_lower : "HELLO" => "vlllu" => "hellO", 0; + vi_v_U_upper : "hello" => "vlllU" => "HELLo", 0; + vi_v_d_delete : "hello world" => "vwwd" => "", 0; + vi_v_x_delete : "hello world" => "vwwx" => "", 0; + vi_v_c_change : "hello world" => "vwcfoo\x1b" => "fooorld", 2; + vi_v_y_p_yank : "hello world" => "vwyAp \x1bp" => "hello worldp hello w", 19; + vi_v_dollar_d : "hello world" => "wv$d" => "hello ", 5; + vi_v_0_d : "hello world" => "$v0d" => "", 0; + vi_ve_d : "hello world" => "ved" => " world", 0; + vi_v_o_swap : "hello world" => "vllod" => "lo world", 0; + vi_v_r_replace : "hello" => "vlllrx" => "xxxxo", 0; + vi_v_tilde_case : "hello" => "vlll~" => "HELLo", 0; + vi_V_d_delete : "hello world" => "Vd" => "", 0; + vi_V_y_p : "hello world" => "Vyp" => "hello world\nhello world", 12; + vi_V_S_change : "hello world" => "VSfoo\x1b" => "foo", 2; + vi_ctrl_a_inc : "num 5 end" => "w\x01" => "num 6 end", 4; + vi_ctrl_x_dec : "num 5 end" => "w\x18" => "num 4 end", 4; + vi_ctrl_a_negative : "num -3 end" => "w\x01" => "num -2 end", 4; + vi_ctrl_x_to_neg : "num 0 end" => "w\x18" => "num -1 end", 4; + vi_ctrl_a_count : "num 5 end" => "w3\x01" => "num 8 end", 4; + vi_ctrl_a_width : "num -00001 end" => "w\x01" => "num 00000 end", 4; + vi_delete_empty : "" => "x" => "", 0; + vi_undo_on_empty : "" => "u" => "", 0; + vi_w_single_char : "a b c" => "w" => "a b c", 2; + vi_dw_last_word : "hello" => "dw" => "", 0; + vi_dollar_single : "h" => "$" => "h", 0; + vi_caret_no_ws : "hello" => "$^" => "hello", 0; + vi_f_last_char : "hello" => "fo" => "hello", 4; + vi_r_on_space : "hello world" => "5|r-" => "hell- world", 4 +} diff --git a/src/readline/vicmd.rs b/src/readline/vicmd.rs index 8763f36..47f4e54 100644 --- a/src/readline/vicmd.rs +++ b/src/readline/vicmd.rs @@ -343,7 +343,7 @@ pub enum Motion { HalfOfScreen, HalfOfScreenLineText, WholeBuffer, - BeginningOfBuffer, + StartOfBuffer, EndOfBuffer, ToColumn, ToDelimMatch, diff --git a/src/readline/vimode/insert.rs b/src/readline/vimode/insert.rs index e314355..f55a1c4 100644 --- a/src/readline/vimode/insert.rs +++ b/src/readline/vimode/insert.rs @@ -13,6 +13,10 @@ impl ViInsert { pub fn new() -> Self { Self::default() } + pub fn record_cmd(mut self, cmd: ViCmd) -> Self { + self.cmds.push(cmd); + self + } pub fn with_count(mut self, repeat_count: u16) -> Self { self.repeat_count = repeat_count; self diff --git a/src/readline/vimode/normal.rs b/src/readline/vimode/normal.rs index 469ac83..3b2b0d1 100644 --- a/src/readline/vimode/normal.rs +++ b/src/readline/vimode/normal.rs @@ -434,7 +434,7 @@ impl ViNormal { 'g' => { chars_clone.next(); chars = chars_clone; - break 'motion_parse Some(MotionCmd(count, Motion::BeginningOfBuffer)); + break 'motion_parse Some(MotionCmd(count, Motion::StartOfBuffer)); } 'e' => { chars = chars_clone; diff --git a/src/readline/vimode/verbatim.rs b/src/readline/vimode/verbatim.rs index 7dad52f..0673133 100644 --- a/src/readline/vimode/verbatim.rs +++ b/src/readline/vimode/verbatim.rs @@ -4,19 +4,11 @@ use crate::readline::vicmd::{CmdFlags, RegisterName, To, Verb, VerbCmd, ViCmd}; #[derive(Default, Clone, Debug)] pub struct ViVerbatim { - pending_seq: String, sent_cmd: Vec, repeat_count: u16, - read_one: bool } impl ViVerbatim { - pub fn read_one() -> Self { - Self { - read_one: true, - ..Self::default() - } - } pub fn new() -> Self { Self::default() } @@ -31,7 +23,7 @@ impl ViVerbatim { impl ViMode for ViVerbatim { fn handle_key(&mut self, key: E) -> Option { match key { - E(K::Verbatim(seq), _mods) if self.read_one => { + E(K::Verbatim(seq), _mods) => { log::debug!("Received verbatim key sequence: {:?}", seq); let cmd = ViCmd { register: RegisterName::default(), @@ -43,22 +35,6 @@ impl ViMode for ViVerbatim { self.sent_cmd.push(cmd.clone()); Some(cmd) } - E(K::Verbatim(seq), _mods) => { - self.pending_seq.push_str(&seq); - None - } - E(K::BracketedPasteEnd, _mods) => { - log::debug!("Received verbatim paste: {:?}", self.pending_seq); - let cmd = ViCmd { - register: RegisterName::default(), - verb: Some(VerbCmd(1, Verb::Insert(self.pending_seq.clone()))), - motion: None, - raw_seq: std::mem::take(&mut self.pending_seq), - flags: CmdFlags::EXIT_CUR_MODE, - }; - self.sent_cmd.push(cmd.clone()); - Some(cmd) - } _ => common_cmds(key), } } diff --git a/src/readline/vimode/visual.rs b/src/readline/vimode/visual.rs index 37b9f01..ba9e5a9 100644 --- a/src/readline/vimode/visual.rs +++ b/src/readline/vimode/visual.rs @@ -213,7 +213,7 @@ impl ViVisual { let ch = chars_clone.next()?; return Some(ViCmd { register, - verb: Some(VerbCmd(1, Verb::ReplaceChar(ch))), + verb: Some(VerbCmd(1, Verb::ReplaceCharInplace(ch,1))), motion: None, raw_seq: self.take_cmd(), flags: CmdFlags::empty(), @@ -237,6 +237,24 @@ impl ViVisual { flags: CmdFlags::empty(), }); } + 's' => { + return Some(ViCmd { + register, + verb: Some(VerbCmd(count, Verb::Delete)), + motion: None, + raw_seq: self.take_cmd(), + flags: CmdFlags::empty(), + }); + } + 'S' => { + return Some(ViCmd { + register, + verb: Some(VerbCmd(count, Verb::Change)), + motion: None, + raw_seq: self.take_cmd(), + flags: CmdFlags::empty(), + }); + } 'U' => { return Some(ViCmd { register, @@ -283,8 +301,13 @@ impl ViVisual { }); } 'y' => { - chars = chars_clone; - break 'verb_parse Some(VerbCmd(count, Verb::Yank)); + return Some(ViCmd { + register, + verb: Some(VerbCmd(count, Verb::Yank)), + motion: None, + raw_seq: self.take_cmd(), + flags: CmdFlags::empty(), + }); } 'd' => { chars = chars_clone; @@ -335,7 +358,7 @@ impl ViVisual { 'g' => { chars_clone.next(); chars = chars_clone; - break 'motion_parse Some(MotionCmd(count, Motion::BeginningOfBuffer)); + break 'motion_parse Some(MotionCmd(count, Motion::StartOfBuffer)); } 'e' => { chars_clone.next(); diff --git a/src/testutil.rs b/src/testutil.rs index 7c5ddc4..65da6c4 100644 --- a/src/testutil.rs +++ b/src/testutil.rs @@ -1,7 +1,7 @@ use std::{ collections::{HashMap, HashSet}, env, - os::fd::{AsRawFd, OwnedFd}, + os::fd::{AsRawFd, BorrowedFd, OwnedFd}, path::PathBuf, sync::{self, Arc, MutexGuard}, }; @@ -14,7 +14,7 @@ use nix::{ }; use crate::{ - expand::expand_aliases, libsh::error::ShResult, parse::{ParsedSrc, Redir, RedirType, execute::exec_input, lex::LexFlags}, procio::{IoFrame, IoMode, RedirGuard}, state::{MetaTab, SHED, read_logic} + expand::expand_aliases, libsh::error::ShResult, parse::{ParsedSrc, Redir, RedirType, execute::exec_input, lex::LexFlags}, procio::{IoFrame, IoMode, RedirGuard}, readline::register::{restore_registers, save_registers}, state::{MetaTab, SHED, read_logic} }; static TEST_MUTEX: sync::Mutex<()> = sync::Mutex::new(()); @@ -38,7 +38,7 @@ pub struct TestGuard { old_cwd: PathBuf, saved_env: HashMap, pty_master: OwnedFd, - _pty_slave: OwnedFd, + pty_slave: OwnedFd, cleanups: Vec> } @@ -87,17 +87,22 @@ impl TestGuard { let old_cwd = env::current_dir().unwrap(); let saved_env = env::vars().collect(); SHED.with(|s| s.save()); + save_registers(); Self { _lock, _redir_guard, old_cwd, saved_env, pty_master, - _pty_slave: pty_slave, + pty_slave, cleanups: vec![], } } + pub fn pty_slave(&self) -> BorrowedFd { + unsafe { BorrowedFd::borrow_raw(self.pty_slave.as_raw_fd()) } + } + pub fn add_cleanup(&mut self, f: impl FnOnce() + 'static) { self.cleanups.push(Box::new(f)); } @@ -148,6 +153,7 @@ impl Drop for TestGuard { cleanup(); } SHED.with(|s| s.restore()); + restore_registers(); } } @@ -221,6 +227,7 @@ pub enum NdKind { Conjunction, Assignment, BraceGrp, + Negate, Test, FuncDef, } @@ -228,6 +235,7 @@ pub enum NdKind { impl crate::parse::NdRule { pub fn as_nd_kind(&self) -> NdKind { match self { + Self::Negate { .. } => NdKind::Negate, Self::IfNode { .. } => NdKind::IfNode, Self::LoopNode { .. } => NdKind::LoopNode, Self::ForNode { .. } => NdKind::ForNode, diff --git a/tests/gen_vi_tests.lua b/tests/gen_vi_tests.lua new file mode 100644 index 0000000..a5c1d83 --- /dev/null +++ b/tests/gen_vi_tests.lua @@ -0,0 +1,319 @@ +-- Generate Rust vi_test! macro invocations using neovim as oracle +-- Usage: nvim --headless --clean -l tests/gen_vi_tests.lua +-- +-- Define test cases as { name, input_text, key_sequence } +-- Key sequences use vim notation: , , , etc. +-- The script executes each in a fresh buffer and captures the result. + +local tests = { + -- ===================== basic char motions ===================== + { "dw_basic", "hello world", "dw" }, + { "dw_middle", "one two three", "wdw" }, + { "dd_whole_line", "hello world", "dd" }, + { "x_single", "hello", "x" }, + { "x_middle", "hello", "llx" }, + { "X_backdelete", "hello", "llX" }, + { "h_motion", "hello", "$h" }, + { "l_motion", "hello", "l" }, + { "h_at_start", "hello", "h" }, + { "l_at_end", "hello", "$l" }, + + -- ===================== word motions (small) ===================== + { "w_forward", "one two three", "w" }, + { "b_backward", "one two three", "$b" }, + { "e_end", "one two three", "e" }, + { "ge_back_end", "one two three", "$ge" }, + { "w_punctuation", "foo.bar baz", "w" }, + { "e_punctuation", "foo.bar baz", "e" }, + { "b_punctuation", "foo.bar baz", "$b" }, + { "w_at_eol", "hello", "$w" }, + { "b_at_bol", "hello", "b" }, + + -- ===================== word motions (big) ===================== + { "W_forward", "foo.bar baz", "W" }, + { "B_backward", "foo.bar baz", "$B" }, + { "E_end", "foo.bar baz", "E" }, + { "gE_back_end", "one two three", "$gE" }, + { "W_skip_punct", "one-two three", "W" }, + { "B_skip_punct", "one two-three", "$B" }, + { "E_skip_punct", "one-two three", "E" }, + { "dW_big", "foo.bar baz", "dW" }, + { "cW_big", "foo.bar baz", "cWx" }, + + -- ===================== line motions ===================== + { "zero_bol", " hello", "$0" }, + { "caret_first_char", " hello", "$^" }, + { "dollar_eol", "hello world", "$" }, + { "g_last_nonws", "hello ", "g_" }, + { "g_no_trailing", "hello", "g_" }, + { "pipe_column", "hello world", "6|" }, + { "pipe_col1", "hello world", "1|" }, + { "I_insert_front", " hello", "Iworld " }, + { "A_append_end", "hello", "A world" }, + + -- ===================== find motions ===================== + { "f_find", "hello world", "fo" }, + { "F_find_back", "hello world", "$Fo" }, + { "t_till", "hello world", "tw" }, + { "T_till_back", "hello world", "$To" }, + { "f_no_match", "hello", "fz" }, + { "semicolon_repeat", "abcabc", "fa;;" }, + { "comma_reverse", "abcabc", "fa;;," }, + { "df_semicolon", "abcabc", "fa;;dfa" }, + { "t_at_target", "aab", "lta" }, + + -- ===================== delete operations ===================== + { "D_to_end", "hello world", "wD" }, + { "d_dollar", "hello world", "wd$" }, + { "d0_to_start", "hello world", "$d0" }, + { "dw_multiple", "one two three", "d2w" }, + { "dt_char", "hello world", "dtw" }, + { "df_char", "hello world", "dfw" }, + { "dh_back", "hello", "lldh" }, + { "dl_forward", "hello", "dl" }, + { "dge_back_end", "one two three", "$dge" }, + { "dG_to_end", "hello world", "dG" }, + { "dgg_to_start", "hello world", "$dgg" }, + { "d_semicolon", "abcabc", "fad;" }, + + -- ===================== change operations ===================== + { "cw_basic", "hello world", "cwfoo" }, + { "C_to_end", "hello world", "wCfoo" }, + { "cc_whole", "hello world", "ccfoo" }, + { "ct_char", "hello world", "ctwfoo" }, + { "s_single", "hello", "sfoo" }, + { "S_whole_line", "hello world", "Sfoo" }, + { "cl_forward", "hello", "clX" }, + { "ch_backward", "hello", "llchX" }, + { "cb_word_back", "hello world", "$cbfoo" }, + { "ce_word_end", "hello world", "cefoo" }, + { "c0_to_start", "hello world", "wc0foo" }, + + -- ===================== yank and paste ===================== + { "yw_p_basic", "hello world", "ywwP" }, + { "dw_p_paste", "hello world", "dwP" }, + { "dd_p_paste", "hello world", "ddp" }, + { "y_dollar_p", "hello world", "wy$P" }, + { "ye_p", "hello world", "yewP" }, + { "yy_p", "hello world", "yyp" }, + { "Y_p", "hello world", "Yp" }, + { "p_after_x", "hello", "xp" }, + { "P_before", "hello", "llxP" }, + { "paste_empty", "hello", "p" }, + + -- ===================== replace ===================== + { "r_replace", "hello", "ra" }, + { "r_middle", "hello", "llra" }, + { "r_at_end", "hello", "$ra" }, + { "r_space", "hello", "r " }, + { "r_with_count", "hello", "3rx" }, + + -- ===================== case operations ===================== + { "tilde_single", "hello", "~" }, + { "tilde_count", "hello", "3~" }, + { "tilde_at_end", "HELLO", "$~" }, + { "tilde_mixed", "hElLo", "5~" }, + { "gu_word", "HELLO world", "guw" }, + { "gU_word", "hello WORLD", "gUw" }, + { "gu_dollar", "HELLO WORLD", "gu$" }, + { "gU_dollar", "hello world", "gU$" }, + { "gu_0", "HELLO WORLD", "$gu0" }, + { "gU_0", "hello world", "$gU0" }, + { "gtilde_word", "hello WORLD", "g~w" }, + { "gtilde_dollar", "hello WORLD", "g~$" }, + + -- ===================== text objects: word ===================== + { "diw_inner", "one two three", "wdiw" }, + { "ciw_replace", "hello world", "ciwfoo" }, + { "daw_around", "one two three", "wdaw" }, + { "yiw_p", "hello world", "yiwAp p" }, + { "diW_big_inner", "one-two three", "diW" }, + { "daW_big_around", "one two-three end", "wdaW" }, + { "ciW_big", "one-two three", "ciWx" }, + + -- ===================== text objects: quotes ===================== + { "di_dquote", 'one "two" three', 'f"di"' }, + { "da_dquote", 'one "two" three', 'f"da"' }, + { "ci_dquote", 'one "two" three', 'f"ci"x' }, + { "di_squote", "one 'two' three", "f'di'" }, + { "da_squote", "one 'two' three", "f'da'" }, + { "di_backtick", "one `two` three", "f`di`" }, + { "da_backtick", "one `two` three", "f`da`" }, + { "ci_dquote_empty", 'one "" three', 'f"ci"x' }, + + -- ===================== text objects: delimiters ===================== + { "di_paren", "one (two) three", "f(di(" }, + { "da_paren", "one (two) three", "f(da(" }, + { "ci_paren", "one (two) three", "f(ci(x" }, + { "di_brace", "one {two} three", "f{di{" }, + { "da_brace", "one {two} three", "f{da{" }, + { "di_bracket", "one [two] three", "f[di[" }, + { "da_bracket", "one [two] three", "f[da[" }, + { "di_angle", "one three", "f three", "f" }, + { "a_append", "hello", "aX" }, + { "I_front", " hello", "IX" }, + { "A_end", "hello", "AX" }, + { "o_open_below", "hello", "oworld" }, + { "O_open_above", "hello", "Oworld" }, + + -- ===================== insert mode operations ===================== + { "empty_input", "", "i hello" }, + { "insert_escape", "hello", "aX" }, + { "ctrl_w_del_word", "hello world", "A" }, + { "ctrl_h_backspace", "hello", "A" }, + + -- ===================== undo / redo ===================== + { "u_undo_delete", "hello world", "dwu" }, + { "u_undo_change", "hello world", "ciwfoou" }, + { "u_undo_x", "hello", "xu" }, + { "ctrl_r_redo", "hello", "xu" }, + { "u_multiple", "hello world", "xdwu" }, + { "redo_after_undo", "hello world", "dwu" }, + + -- ===================== dot repeat ===================== + { "dot_repeat_x", "hello", "x." }, + { "dot_repeat_dw", "one two three", "dw." }, + { "dot_repeat_cw", "one two three", "cwfoow." }, + { "dot_repeat_r", "hello", "ra.." }, + { "dot_repeat_s", "hello", "sXl." }, + + -- ===================== counts ===================== + { "count_h", "hello world", "$3h" }, + { "count_l", "hello world", "3l" }, + { "count_w", "one two three four", "2w" }, + { "count_b", "one two three four", "$2b" }, + { "count_x", "hello", "3x" }, + { "count_dw", "one two three four", "2dw" }, + { "verb_count_motion", "one two three four", "d2w" }, + { "count_s", "hello", "3sX" }, + + -- ===================== indent / dedent ===================== + { "indent_line", "hello", ">>" }, + { "dedent_line", "\thello", "<<" }, + { "indent_double", "hello", ">>>>" }, + + -- ===================== join ===================== + { "J_join_lines", "hello\nworld", "J" }, + + -- ===================== case in visual ===================== + { "v_u_lower", "HELLO", "vlllu" }, + { "v_U_upper", "hello", "vlllU" }, + + -- ===================== visual mode ===================== + { "v_d_delete", "hello world", "vwwd" }, + { "v_x_delete", "hello world", "vwwx" }, + { "v_c_change", "hello world", "vwcfoo" }, + { "v_y_p_yank", "hello world", "vwyAp p" }, + { "v_dollar_d", "hello world", "wv$d" }, + { "v_0_d", "hello world", "$v0d" }, + { "ve_d", "hello world", "ved" }, + { "v_o_swap", "hello world", "vllod" }, + { "v_r_replace", "hello", "vlllrx" }, + { "v_tilde_case", "hello", "vlll~" }, + + -- ===================== visual line mode ===================== + { "V_d_delete", "hello world", "Vd" }, + { "V_y_p", "hello world", "Vyp" }, + { "V_S_change", "hello world", "VSfoo" }, + + -- ===================== increment / decrement ===================== + { "ctrl_a_inc", "num 5 end", "w" }, + { "ctrl_x_dec", "num 5 end", "w" }, + { "ctrl_a_negative", "num -3 end", "w" }, + { "ctrl_x_to_neg", "num 0 end", "w" }, + { "ctrl_a_count", "num 5 end", "w3" }, + + -- ===================== misc / edge cases ===================== + { "delete_empty", "", "x" }, + { "undo_on_empty", "", "u" }, + { "w_single_char", "a b c", "w" }, + { "dw_last_word", "hello", "dw" }, + { "dollar_single", "h", "$" }, + { "caret_no_ws", "hello", "$^" }, + { "f_last_char", "hello", "fo" }, + { "r_on_space", "hello world", "5|r-" }, +} + +-- Map vim special key names to Rust string escape sequences +local key_to_bytes = { + [""] = "\\x1b", + [""] = "\\r", + [""] = "\\x7f", + [""] = "\\t", + [""] = "\\x1b[3~", + [""] = "\\x1b[A", + [""] = "\\x1b[B", + [""] = "\\x1b[C", + [""] = "\\x1b[D", + [""] = "\\x1b[H", + [""] = "\\x1b[F", +} + +-- Convert vim key notation to Rust string escape sequences +local function keys_to_rust(keys) + local result = keys + result = result:gsub("", function(ch) + local byte = string.byte(ch:lower()) - string.byte('a') + 1 + return string.format("\\x%02x", byte) + end) + for name, bytes in pairs(key_to_bytes) do + result = result:gsub(vim.pesc(name), bytes) + end + return result +end + +-- Escape a string for use in a Rust string literal +local function rust_escape(s) + return s:gsub("\\", "\\\\"):gsub('"', '\\"'):gsub("\n", "\\n"):gsub("\t", "\\t") +end + +io.write("vi_test! {\n") + +for i, test in ipairs(tests) do + local name, input, keys = test[1], test[2], test[3] + + -- Fresh buffer and register state + local input_lines = vim.split(input, "\n", { plain = true }) + vim.api.nvim_buf_set_lines(0, 0, -1, false, input_lines) + vim.api.nvim_win_set_cursor(0, { 1, 0 }) + vim.fn.setreg('"', '') + + -- Execute the key sequence synchronously + local translated = vim.api.nvim_replace_termcodes(keys, true, false, true) + vim.api.nvim_feedkeys(translated, "ntx", false) + vim.api.nvim_exec_autocmds("CursorMoved", {}) + + -- Capture result + local lines = vim.api.nvim_buf_get_lines(0, 0, -1, false) + local result = table.concat(lines, "\n") + local cursor_col = vim.api.nvim_win_get_cursor(0)[2] + + local rust_keys = keys_to_rust(keys) + local rust_input = rust_escape(input) + local rust_result = rust_escape(result) + + local sep = ";" + if i == #tests then sep = "" end + + io.write(string.format('\tvi_%s: "%s" => "%s" => "%s", %d%s\n', + name, rust_input, rust_keys, rust_result, cursor_col, sep)) +end + +io.write("}\n") + +vim.cmd("qa!")