Add ! negation support, fix POSIX exit statuses, and improve vi emulation with comprehensive tests

This commit is contained in:
2026-03-07 21:57:04 -05:00
parent 490ce4571d
commit 07d7015dd4
16 changed files with 1240 additions and 211 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,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<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,
@@ -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;

View File

@@ -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<Tk>,
body: Vec<Node>,
},
Negate {
cmd: Box<Node>,
},
CaseNode {
pattern: Tk,
case_blocks: Vec<CaseNode>,
@@ -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<Option<Node>> {
let mut node_tks: Vec<Tk> = 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<Option<Node>> {
// 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: _ } => (),
}
}