Compare commits

...

2 Commits

16 changed files with 1955 additions and 220 deletions

View File

@@ -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,10 +726,14 @@ impl Dispatcher {
}
}
if !matched && !else_block.is_empty() {
if !matched {
if !else_block.is_empty() {
for node in else_block {
s.dispatch_node(node)?;
}
} else {
state::set_status(0);
}
}
Ok(())
@@ -1195,3 +1211,191 @@ pub fn is_func(tk: Option<Tk>) -> bool {
pub fn is_subsh(tk: Option<Tk>) -> 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");
}
}

View File

@@ -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,
@@ -250,6 +251,7 @@ pub struct LexStream {
quote_state: QuoteState,
brc_grp_depth: usize,
brc_grp_start: Option<usize>,
case_depth: usize,
flags: LexFlags,
}
@@ -271,7 +273,6 @@ bitflags! {
/// The lexer has no more tokens to produce
const STALE = 0b0001000000;
const EXPECTING_IN = 0b0010000000;
const IN_CASE = 0b0100000000;
}
}
@@ -306,6 +307,7 @@ impl LexStream {
quote_state: QuoteState::default(),
brc_grp_depth: 0,
brc_grp_start: None,
case_depth: 0,
}
}
/// Returns a slice of the source input using the given range
@@ -453,7 +455,7 @@ impl LexStream {
let mut chars = slice.chars().peekable();
let can_be_subshell = chars.peek() == Some(&'(');
if self.flags.contains(LexFlags::IN_CASE)
if self.case_depth > 0
&& let Some(count) = case_pat_lookahead(chars.clone())
{
pos += count;
@@ -731,7 +733,7 @@ impl LexStream {
"case" | "select" | "for" => {
new_tk.mark(TkFlags::KEYWORD);
self.flags |= LexFlags::EXPECTING_IN;
self.flags |= LexFlags::IN_CASE;
self.case_depth += 1;
self.set_next_is_cmd(false);
}
"in" if self.flags.contains(LexFlags::EXPECTING_IN) => {
@@ -739,8 +741,8 @@ impl LexStream {
self.flags &= !LexFlags::EXPECTING_IN;
}
_ if is_keyword(text) => {
if text == "esac" && self.flags.contains(LexFlags::IN_CASE) {
self.flags &= !LexFlags::IN_CASE;
if text == "esac" && self.case_depth > 0 {
self.case_depth -= 1;
}
new_tk.mark(TkFlags::KEYWORD);
}
@@ -881,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;

File diff suppressed because one or more lines are too long

View File

@@ -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<Self> {
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,

View File

@@ -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 {
@@ -836,6 +836,34 @@ impl LineBuf {
};
(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<dyn Iterator<Item = usize>> {
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<dyn Iterator<Item = usize>>,
Direction::Backward => Box::new(self.grapheme_indices().to_vec().into_iter().take(pos).rev())
as Box<dyn Iterator<Item = usize>>,
Direction::Forward => Box::new(pos + 1..len) as Box<dyn Iterator<Item = usize>>,
Direction::Backward => Box::new((0..pos).rev()) as Box<dyn Iterator<Item = usize>>,
}
}
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::StartOfBuffer) => {
MotionKind::InclusiveWithTargetCol((0, self.end_of_line()), 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())
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::ToColumn) => todo!(),
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,6 +2676,12 @@ impl LineBuf {
self.apply_motion(motion);
}
Verb::ReplaceCharInplace(ch, count) => {
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);
@@ -2657,7 +2694,9 @@ impl LineBuf {
}
}
}
}
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
match anchor {
Anchor::After => {
let insert_idx = self
.cursor
.get()
.saturating_add(1)
.min(self.grapheme_indices().len()),
Anchor::Before => self.cursor.get(),
};
.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));
},
};
}
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::<i64>() {
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))
}
}

View File

@@ -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<Self> {
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,10 +1107,9 @@ 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 mut mode: Box<dyn ViMode> = if matches!(
@@ -1092,12 +1126,15 @@ impl ShedVi {
match cmd.verb().unwrap().1 {
Verb::Change | Verb::InsertModeLineBreak(_) | Verb::InsertMode => {
is_insert_mode = true;
Box::new(ViInsert::new().with_count(count as u16))
Box::new(ViInsert::new().with_count(count as u16).record_cmd(cmd.clone()))
}
Verb::ExMode => Box::new(ViEx::new()),
Verb::VerbatimMode => Box::new(ViVerbatim::read_one().with_count(count as u16)),
Verb::VerbatimMode => {
self.reader.verbatim_single = true;
Box::new(ViVerbatim::new().with_count(count as u16))
}
Verb::NormalMode => Box::new(ViNormal::new()),
@@ -1145,7 +1182,7 @@ impl ShedVi {
return Ok(());
}
if mode.is_repeatable() {
if mode.is_repeatable() && !from_replay {
self.repeat_action = mode.as_replay();
}
@@ -1174,7 +1211,24 @@ impl ShedVi {
})?;
self.prompt.refresh();
return Ok(());
Ok(())
}
pub fn clone_mode(&self) -> Box<dyn ViMode> {
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,12 +1240,37 @@ 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<dyn ViMode>,
ModeReport::Insert => Box::new(ViInsert::new()) as Box<dyn ViMode>,
ModeReport::Visual => Box::new(ViVisual::new()) as Box<dyn ViMode>,
ModeReport::Ex => Box::new(ViEx::new()) as Box<dyn ViMode>,
ModeReport::Replace => Box::new(ViReplace::new()) as Box<dyn ViMode>,
ModeReport::Verbatim => Box::new(ViVerbatim::new()) as Box<dyn ViMode>,
ModeReport::Unknown => unreachable!(),
};
self.mode = old_mode_clone;
}
}
CmdReplay::Single(mut cmd) => {
if count > 1 {
@@ -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<dyn ViMode> = 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<Marker> {
match class {
TkRule::Pipe
| TkRule::Bang
| TkRule::ErrPipe
| TkRule::And
| TkRule::Or

View File

@@ -2,6 +2,24 @@ use std::{fmt::Display, sync::Mutex};
pub static REGISTERS: Mutex<Registers> = Mutex::new(Registers::new());
#[cfg(test)]
pub static SAVED_REGISTERS: Mutex<Option<Registers>> = 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<char>) -> Option<RegisterContent> {
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,

View File

@@ -499,6 +499,7 @@ pub struct PollReader {
parser: Parser,
collector: KeyCollector,
byte_buf: VecDeque<u8>,
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<KeyEvent> {
if self.byte_buf.is_empty() {
return None;
}
let bytes: Vec<u8> = 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,18 +555,29 @@ impl Default for PollReader {
impl KeyReader for PollReader {
fn read_key(&mut self) -> Result<Option<KeyEvent>, 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
} 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]);
if let Some(key) = self.collector.pop() {

229
src/readline/tests.rs Normal file
View File

@@ -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 <two> three" => "f<di<" => "one <> three", 5;
vi_da_angle : "one <two> three" => "f<da<" => "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
}

View File

@@ -343,7 +343,7 @@ pub enum Motion {
HalfOfScreen,
HalfOfScreenLineText,
WholeBuffer,
BeginningOfBuffer,
StartOfBuffer,
EndOfBuffer,
ToColumn,
ToDelimMatch,

View File

@@ -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

View File

@@ -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;

View File

@@ -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<ViCmd>,
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<ViCmd> {
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),
}
}

View File

@@ -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();

View File

@@ -1,9 +1,9 @@
use std::{
collections::HashMap,
collections::{HashMap, HashSet},
env,
os::fd::{AsRawFd, OwnedFd},
os::fd::{AsRawFd, BorrowedFd, OwnedFd},
path::PathBuf,
sync::{self, MutexGuard},
sync::{self, Arc, MutexGuard},
};
use nix::{
@@ -14,10 +14,7 @@ use nix::{
};
use crate::{
libsh::error::ShResult,
parse::{Redir, RedirType, execute::exec_input},
procio::{IoFrame, IoMode, RedirGuard},
state::{MetaTab, SHED},
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(());
@@ -41,7 +38,7 @@ pub struct TestGuard {
old_cwd: PathBuf,
saved_env: HashMap<String, String>,
pty_master: OwnedFd,
_pty_slave: OwnedFd,
pty_slave: OwnedFd,
cleanups: Vec<Box<dyn FnOnce()>>
}
@@ -90,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));
}
@@ -151,5 +153,100 @@ impl Drop for TestGuard {
cleanup();
}
SHED.with(|s| s.restore());
restore_registers();
}
}
pub fn get_ast(input: &str) -> ShResult<Vec<crate::parse::Node>> {
let log_tab = read_logic(|l| l.clone());
let input = expand_aliases(input.into(), HashSet::new(), &log_tab);
let source_name = "test_input".to_string();
let mut parser = ParsedSrc::new(Arc::new(input))
.with_lex_flags(LexFlags::empty())
.with_name(source_name.clone());
parser.parse_src().map_err(|e| e.into_iter().next().unwrap())?;
Ok(parser.extract_nodes())
}
impl crate::parse::Node {
pub fn assert_structure(&mut self, expected: &mut impl Iterator<Item = NdKind>) -> Result<(), String> {
let mut full_structure = vec![];
let mut before = vec![];
let mut after = vec![];
let mut offender = None;
self.walk_tree(&mut |s| {
let expected_rule = expected.next();
full_structure.push(s.class.as_nd_kind());
if offender.is_none() && expected_rule.as_ref().map_or(true, |e| *e != s.class.as_nd_kind()) {
offender = Some((s.class.as_nd_kind(), expected_rule));
} else if offender.is_none() {
before.push(s.class.as_nd_kind());
} else {
after.push(s.class.as_nd_kind());
}
});
assert!(expected.next().is_none(), "Expected structure has more nodes than actual structure");
if let Some((nd_kind, expected_rule)) = offender {
let expected_rule = expected_rule.map_or("(none — expected array too short)".into(), |e| format!("{e:?}"));
let full_structure_hint = full_structure.into_iter()
.map(|s| format!("\tNdKind::{s:?},"))
.collect::<Vec<String>>()
.join("\n");
let full_structure_hint = format!("let expected = &mut [\n{full_structure_hint}\n].into_iter();");
let output = [
"Structure assertion failed!\n".into(),
format!("Expected node type '{:?}', found '{:?}'", expected_rule, nd_kind),
format!("Before offender: {:?}", before),
format!("After offender: {:?}\n", after),
format!("hint: here is the full structure as an array\n {full_structure_hint}"),
].join("\n");
Err(output)
} else {
Ok(())
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub enum NdKind {
IfNode,
LoopNode,
ForNode,
CaseNode,
Command,
Pipeline,
Conjunction,
Assignment,
BraceGrp,
Negate,
Test,
FuncDef,
}
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,
Self::CaseNode { .. } => NdKind::CaseNode,
Self::Command { .. } => NdKind::Command,
Self::Pipeline { .. } => NdKind::Pipeline,
Self::Conjunction { .. } => NdKind::Conjunction,
Self::Assignment { .. } => NdKind::Assignment,
Self::BraceGrp { .. } => NdKind::BraceGrp,
Self::Test { .. } => NdKind::Test,
Self::FuncDef { .. } => NdKind::FuncDef,
}
}
}

319
tests/gen_vi_tests.lua Normal file
View File

@@ -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: <Esc>, <CR>, <C-w>, 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<Esc>" },
-- ===================== 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 <Esc>" },
{ "A_append_end", "hello", "A world<Esc>" },
-- ===================== 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<Esc>" },
{ "C_to_end", "hello world", "wCfoo<Esc>" },
{ "cc_whole", "hello world", "ccfoo<Esc>" },
{ "ct_char", "hello world", "ctwfoo<Esc>" },
{ "s_single", "hello", "sfoo<Esc>" },
{ "S_whole_line", "hello world", "Sfoo<Esc>" },
{ "cl_forward", "hello", "clX<Esc>" },
{ "ch_backward", "hello", "llchX<Esc>" },
{ "cb_word_back", "hello world", "$cbfoo<Esc>" },
{ "ce_word_end", "hello world", "cefoo<Esc>" },
{ "c0_to_start", "hello world", "wc0foo<Esc>" },
-- ===================== 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<Esc>" },
{ "daw_around", "one two three", "wdaw" },
{ "yiw_p", "hello world", "yiwAp <Esc>p" },
{ "diW_big_inner", "one-two three", "diW" },
{ "daW_big_around", "one two-three end", "wdaW" },
{ "ciW_big", "one-two three", "ciWx<Esc>" },
-- ===================== 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<Esc>' },
{ "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<Esc>' },
-- ===================== 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<Esc>" },
{ "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 <two> three", "f<di<" },
{ "da_angle", "one <two> three", "f<da<" },
{ "di_paren_nested", "fn(a, (b, c))", "f(di(" },
{ "di_paren_empty", "fn() end", "f(di(" },
{ "dib_alias", "one (two) three", "f(dib" },
{ "diB_alias", "one {two} three", "f{diB" },
-- ===================== delimiter matching =====================
{ "percent_paren", "(hello) world", "%" },
{ "percent_brace", "{hello} world", "%" },
{ "percent_bracket", "[hello] world", "%" },
{ "percent_from_close", "(hello) world", "f)%" },
{ "d_percent_paren", "(hello) world", "d%" },
-- ===================== insert mode entry =====================
{ "i_insert", "hello", "iX<Esc>" },
{ "a_append", "hello", "aX<Esc>" },
{ "I_front", " hello", "IX<Esc>" },
{ "A_end", "hello", "AX<Esc>" },
{ "o_open_below", "hello", "oworld<Esc>" },
{ "O_open_above", "hello", "Oworld<Esc>" },
-- ===================== insert mode operations =====================
{ "empty_input", "", "i hello<Esc>" },
{ "insert_escape", "hello", "aX<Esc>" },
{ "ctrl_w_del_word", "hello world", "A<C-w><Esc>" },
{ "ctrl_h_backspace", "hello", "A<C-h><Esc>" },
-- ===================== undo / redo =====================
{ "u_undo_delete", "hello world", "dwu" },
{ "u_undo_change", "hello world", "ciwfoo<Esc>u" },
{ "u_undo_x", "hello", "xu" },
{ "ctrl_r_redo", "hello", "xu<C-r>" },
{ "u_multiple", "hello world", "xdwu" },
{ "redo_after_undo", "hello world", "dwu<C-r>" },
-- ===================== dot repeat =====================
{ "dot_repeat_x", "hello", "x." },
{ "dot_repeat_dw", "one two three", "dw." },
{ "dot_repeat_cw", "one two three", "cwfoo<Esc>w." },
{ "dot_repeat_r", "hello", "ra.." },
{ "dot_repeat_s", "hello", "sX<Esc>l." },
-- ===================== 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<Esc>" },
-- ===================== 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<Esc>" },
{ "v_y_p_yank", "hello world", "vwyAp <Esc>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<Esc>" },
-- ===================== increment / decrement =====================
{ "ctrl_a_inc", "num 5 end", "w<C-a>" },
{ "ctrl_x_dec", "num 5 end", "w<C-x>" },
{ "ctrl_a_negative", "num -3 end", "w<C-a>" },
{ "ctrl_x_to_neg", "num 0 end", "w<C-x>" },
{ "ctrl_a_count", "num 5 end", "w3<C-a>" },
-- ===================== 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 = {
["<Esc>"] = "\\x1b",
["<CR>"] = "\\r",
["<BS>"] = "\\x7f",
["<Tab>"] = "\\t",
["<Del>"] = "\\x1b[3~",
["<Up>"] = "\\x1b[A",
["<Down>"] = "\\x1b[B",
["<Right>"] = "\\x1b[C",
["<Left>"] = "\\x1b[D",
["<Home>"] = "\\x1b[H",
["<End>"] = "\\x1b[F",
}
-- Convert vim key notation to Rust string escape sequences
local function keys_to_rust(keys)
local result = keys
result = result:gsub("<C%-(.)>", 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!")