tenseleyflow/rush / 31bc17e

Browse files

feat(executor): implement built-in test command

Add comprehensive test builtin with support for:
- String tests: -z (empty), -n (non-empty), = (equal), != (not equal)
- Numeric comparisons: -eq, -ne, -lt, -le, -gt, -ge
- File tests: -e (exists), -f (file), -d (directory), -r (readable), -w (writable), -x (executable), -s (has size)
- Logical operators: ! (negation), -a (and), -o (or)
- Both 'test' and '[' command aliases

Also fix grammar to allow '=' in bare words (needed for test expressions).
Authored by espadonne
SHA
31bc17e7046ad62dc2900bf9fda01a25c3c48ef9
Parents
d4d46ec
Tree
989043b

4 changed files

StatusFile+-
M crates/rush-executor/src/command.rs 23 0
M crates/rush-executor/src/lib.rs 1 0
A crates/rush-executor/src/test_builtin.rs 199 0
M crates/rush-parser/src/grammar.pest 2 1
crates/rush-executor/src/command.rsmodified
@@ -103,10 +103,33 @@ pub(crate) fn execute_builtin(command: &str, args: &[String]) -> Option<Executio
103103
                 Err(_) => Some(error_result()),
104104
             }
105105
         }
106
+        "test" | "[" => {
107
+            let exit_code = crate::test_builtin::execute_test(args);
108
+            Some(exit_code_to_result(exit_code))
109
+        }
106110
         _ => None,
107111
     }
108112
 }
109113
 
114
+fn exit_code_to_result(code: i32) -> ExecutionResult {
115
+    #[cfg(unix)]
116
+    {
117
+        ExecutionResult {
118
+            exit_status: std::process::ExitStatus::from_raw(code << 8),
119
+        }
120
+    }
121
+
122
+    #[cfg(not(unix))]
123
+    {
124
+        // On non-Unix, we can't easily create an ExitStatus with a specific code
125
+        if code == 0 {
126
+            success_result()
127
+        } else {
128
+            error_result()
129
+        }
130
+    }
131
+}
132
+
110133
 #[cfg(unix)]
111134
 pub(crate) fn success_result() -> ExecutionResult {
112135
     ExecutionResult {
crates/rush-executor/src/lib.rsmodified
@@ -4,6 +4,7 @@ pub mod command;
44
 pub mod pipeline;
55
 pub mod redirect;
66
 pub mod terminal;
7
+pub mod test_builtin;
78
 
89
 pub use command::{execute_command, ExecutionError, ExecutionResult};
910
 pub use pipeline::{execute_and_or_list, execute_pipeline, execute_simple_with_redirects, PipelineError};
crates/rush-executor/src/test_builtin.rsadded
@@ -0,0 +1,199 @@
1
+use std::fs;
2
+use std::path::Path;
3
+
4
+/// Execute the test builtin ([  or test)
5
+///
6
+/// Returns true (0) if the test succeeds, false (1) if it fails
7
+pub fn execute_test(args: &[String]) -> i32 {
8
+    // Handle [ command which requires trailing ]
9
+    let args = if args.last().map(|s| s.as_str()) == Some("]") {
10
+        &args[..args.len() - 1]
11
+    } else {
12
+        args
13
+    };
14
+
15
+    if args.is_empty() {
16
+        return 1; // Empty test is false
17
+    }
18
+
19
+    match evaluate_test(args) {
20
+        Ok(result) => if result { 0 } else { 1 },
21
+        Err(_) => 2, // Syntax error
22
+    }
23
+}
24
+
25
+fn evaluate_test(args: &[String]) -> Result<bool, String> {
26
+    if args.is_empty() {
27
+        return Ok(false);
28
+    }
29
+
30
+    // Handle negation
31
+    if args[0] == "!" {
32
+        return Ok(!evaluate_test(&args[1..])?);
33
+    }
34
+
35
+    // Single argument: non-empty string test
36
+    if args.len() == 1 {
37
+        return Ok(!args[0].is_empty());
38
+    }
39
+
40
+    // Two arguments: unary operators
41
+    if args.len() == 2 {
42
+        return evaluate_unary(&args[0], &args[1]);
43
+    }
44
+
45
+    // Three arguments: binary operators
46
+    if args.len() == 3 {
47
+        return evaluate_binary(&args[0], &args[1], &args[2]);
48
+    }
49
+
50
+    // Four arguments with negation
51
+    if args.len() == 4 && args[0] == "!" {
52
+        return Ok(!evaluate_binary(&args[1], &args[2], &args[3])?);
53
+    }
54
+
55
+    // Complex expressions with -a (and) or -o (or)
56
+    // Find the operator (rightmost for left-to-right evaluation)
57
+    for (i, arg) in args.iter().enumerate() {
58
+        if arg == "-o" && i > 0 && i < args.len() - 1 {
59
+            let left = evaluate_test(&args[..i])?;
60
+            let right = evaluate_test(&args[i + 1..])?;
61
+            return Ok(left || right);
62
+        }
63
+    }
64
+
65
+    for (i, arg) in args.iter().enumerate() {
66
+        if arg == "-a" && i > 0 && i < args.len() - 1 {
67
+            let left = evaluate_test(&args[..i])?;
68
+            let right = evaluate_test(&args[i + 1..])?;
69
+            return Ok(left && right);
70
+        }
71
+    }
72
+
73
+    Err("Invalid test syntax".to_string())
74
+}
75
+
76
+fn evaluate_unary(op: &str, arg: &str) -> Result<bool, String> {
77
+    match op {
78
+        // String tests
79
+        "-z" => Ok(arg.is_empty()),
80
+        "-n" => Ok(!arg.is_empty()),
81
+
82
+        // File tests
83
+        "-e" => Ok(Path::new(arg).exists()),
84
+        "-f" => Ok(Path::new(arg).is_file()),
85
+        "-d" => Ok(Path::new(arg).is_dir()),
86
+        "-r" => Ok(is_readable(arg)),
87
+        "-w" => Ok(is_writable(arg)),
88
+        "-x" => Ok(is_executable(arg)),
89
+        "-s" => Ok(file_has_size(arg)),
90
+
91
+        _ => Err(format!("Unknown unary operator: {}", op)),
92
+    }
93
+}
94
+
95
+fn evaluate_binary(left: &str, op: &str, right: &str) -> Result<bool, String> {
96
+    match op {
97
+        // String comparisons
98
+        "=" | "==" => Ok(left == right),
99
+        "!=" => Ok(left != right),
100
+
101
+        // Numeric comparisons
102
+        "-eq" => compare_numbers(left, right, |a, b| a == b),
103
+        "-ne" => compare_numbers(left, right, |a, b| a != b),
104
+        "-lt" => compare_numbers(left, right, |a, b| a < b),
105
+        "-le" => compare_numbers(left, right, |a, b| a <= b),
106
+        "-gt" => compare_numbers(left, right, |a, b| a > b),
107
+        "-ge" => compare_numbers(left, right, |a, b| a >= b),
108
+
109
+        _ => Err(format!("Unknown binary operator: {}", op)),
110
+    }
111
+}
112
+
113
+fn compare_numbers<F>(left: &str, right: &str, compare: F) -> Result<bool, String>
114
+where
115
+    F: FnOnce(i64, i64) -> bool,
116
+{
117
+    let left_num = left.parse::<i64>()
118
+        .map_err(|_| format!("Not a number: {}", left))?;
119
+    let right_num = right.parse::<i64>()
120
+        .map_err(|_| format!("Not a number: {}", right))?;
121
+
122
+    Ok(compare(left_num, right_num))
123
+}
124
+
125
+fn is_readable(path: &str) -> bool {
126
+    fs::metadata(path).is_ok()
127
+}
128
+
129
+fn is_writable(path: &str) -> bool {
130
+    if let Ok(metadata) = fs::metadata(path) {
131
+        !metadata.permissions().readonly()
132
+    } else {
133
+        false
134
+    }
135
+}
136
+
137
+#[cfg(unix)]
138
+fn is_executable(path: &str) -> bool {
139
+    use std::os::unix::fs::PermissionsExt;
140
+    if let Ok(metadata) = fs::metadata(path) {
141
+        metadata.permissions().mode() & 0o111 != 0
142
+    } else {
143
+        false
144
+    }
145
+}
146
+
147
+#[cfg(not(unix))]
148
+fn is_executable(_path: &str) -> bool {
149
+    // On non-Unix, we can't easily check execute permissions
150
+    Path::new(_path).exists()
151
+}
152
+
153
+fn file_has_size(path: &str) -> bool {
154
+    if let Ok(metadata) = fs::metadata(path) {
155
+        metadata.len() > 0
156
+    } else {
157
+        false
158
+    }
159
+}
160
+
161
+#[cfg(test)]
162
+mod tests {
163
+    use super::*;
164
+
165
+    #[test]
166
+    fn test_string_equal() {
167
+        assert_eq!(execute_test(&["hello".to_string(), "=".to_string(), "hello".to_string()]), 0);
168
+        assert_eq!(execute_test(&["hello".to_string(), "=".to_string(), "world".to_string()]), 1);
169
+    }
170
+
171
+    #[test]
172
+    fn test_string_not_equal() {
173
+        assert_eq!(execute_test(&["hello".to_string(), "!=".to_string(), "world".to_string()]), 0);
174
+        assert_eq!(execute_test(&["hello".to_string(), "!=".to_string(), "hello".to_string()]), 1);
175
+    }
176
+
177
+    #[test]
178
+    fn test_string_empty() {
179
+        assert_eq!(execute_test(&["-z".to_string(), "".to_string()]), 0);
180
+        assert_eq!(execute_test(&["-z".to_string(), "hello".to_string()]), 1);
181
+        assert_eq!(execute_test(&["-n".to_string(), "hello".to_string()]), 0);
182
+        assert_eq!(execute_test(&["-n".to_string(), "".to_string()]), 1);
183
+    }
184
+
185
+    #[test]
186
+    fn test_numeric_comparison() {
187
+        assert_eq!(execute_test(&["5".to_string(), "-eq".to_string(), "5".to_string()]), 0);
188
+        assert_eq!(execute_test(&["5".to_string(), "-eq".to_string(), "6".to_string()]), 1);
189
+        assert_eq!(execute_test(&["3".to_string(), "-lt".to_string(), "5".to_string()]), 0);
190
+        assert_eq!(execute_test(&["5".to_string(), "-lt".to_string(), "3".to_string()]), 1);
191
+        assert_eq!(execute_test(&["7".to_string(), "-gt".to_string(), "5".to_string()]), 0);
192
+    }
193
+
194
+    #[test]
195
+    fn test_negation() {
196
+        assert_eq!(execute_test(&["!".to_string(), "-z".to_string(), "hello".to_string()]), 0);
197
+        assert_eq!(execute_test(&["!".to_string(), "-z".to_string(), "".to_string()]), 1);
198
+    }
199
+}
crates/rush-parser/src/grammar.pestmodified
@@ -123,7 +123,8 @@ command_subst_content = @{ (!(")" | NEWLINE) ~ ANY)* }
123123
 
124124
 // Bare word part: literal text (no special characters)
125125
 // Note: We exclude $, |, &, <, > to handle expansion, pipes, operators, and redirects separately
126
-bare_word_part = @{ (!(" " | "\t" | NEWLINE | "#" | "\"" | "'" | "$" | "=" | "|" | "&" | "<" | ">") ~ ANY)+ }
126
+// "=" is allowed in words (needed for test command), assignments use explicit var_name pattern
127
+bare_word_part = @{ (!(" " | "\t" | NEWLINE | "#" | "\"" | "'" | "$" | "|" | "&" | "<" | ">") ~ ANY)+ }
127128
 
128129
 // Quoted strings (single quotes prevent expansion, double quotes allow it)
129130
 quoted_string = { double_quoted | single_quoted }