pub mod token;
pub mod lexer;
pub mod ast;
pub mod parser;
pub mod host;
pub mod builtins;
#[cfg(feature = "wallet")]
pub mod platform;
pub mod eval;
pub use eval::{Evaluator, ScriptResult, DEFAULT_FUEL, MAX_OUTPUT_BYTES};
pub use host::{BashHost, Output};
pub fn resolve_path(cwd: &str, path: &str) -> String {
builtins::resolve(cwd, path)
}
#[cfg(not(target_arch = "wasm32"))]
pub(crate) type BoxFut<'a, T> = std::pin::Pin<Box<dyn std::future::Future<Output = T> + 'a>>;
#[cfg(target_arch = "wasm32")]
pub(crate) type BoxFut<'a, T> = std::pin::Pin<Box<dyn std::future::Future<Output = T> + 'a>>;
#[derive(Debug, Clone, PartialEq)]
pub struct BashError {
pub kind: BashErrorKind,
pub message: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BashErrorKind {
Parse,
Fuel,
Other,
}
impl BashError {
pub(crate) fn parse(msg: impl Into<String>) -> Self {
Self { kind: BashErrorKind::Parse, message: msg.into() }
}
pub(crate) fn fuel() -> Self {
Self {
kind: BashErrorKind::Fuel,
message: format!(
"fuel exhausted (>{} commands/iterations) — likely an unbounded loop",
eval::DEFAULT_FUEL
),
}
}
pub(crate) fn other(msg: impl Into<String>) -> Self {
Self { kind: BashErrorKind::Other, message: msg.into() }
}
}
impl std::fmt::Display for BashError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let label = match self.kind {
BashErrorKind::Parse => "syntax error",
BashErrorKind::Fuel => "limit",
BashErrorKind::Other => "error",
};
write!(f, "bashlite {label}: {}", self.message)
}
}
impl std::error::Error for BashError {}
pub async fn run<H: BashHost + ?Sized>(
host: &mut H,
source: &str,
) -> Result<ScriptResult, BashError> {
run_with_fuel(host, source, eval::DEFAULT_FUEL).await
}
pub async fn run_with_fuel<H: BashHost + ?Sized>(
host: &mut H,
source: &str,
fuel: u64,
) -> Result<ScriptResult, BashError> {
let tokens = lexer::lex(source)?;
let body = parser::parse(&tokens)?;
Evaluator::new(host).with_fuel(fuel).run(&body).await
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::Result as LhResult;
use crate::filesystem::{
DirEntry, EntryKind, Filesystem, Metadata, WalkEntry,
};
use std::collections::BTreeMap;
use std::sync::Mutex;
#[derive(Debug, Default)]
struct MemFs {
files: Mutex<BTreeMap<String, Vec<u8>>>,
}
impl MemFs {
fn with(files: &[(&str, &str)]) -> Self {
let m = MemFs::default();
{
let mut g = m.files.lock().unwrap();
for (p, c) in files {
g.insert(norm(p), c.as_bytes().to_vec());
}
}
m
}
fn is_dir(&self, dir: &str) -> bool {
let dir = norm(dir);
if dir == "/" {
return true;
}
let prefix = format!("{dir}/");
self.files.lock().unwrap().keys().any(|k| k.starts_with(&prefix))
}
}
fn norm(p: &str) -> String {
let mut stack: Vec<&str> = Vec::new();
for c in p.split('/') {
match c {
"" | "." => {}
".." => {
stack.pop();
}
x => stack.push(x),
}
}
if stack.is_empty() {
"/".to_string()
} else {
format!("/{}", stack.join("/"))
}
}
#[async_trait::async_trait]
impl Filesystem for MemFs {
async fn read(&self, path: &str) -> LhResult<Vec<u8>> {
self.files
.lock()
.unwrap()
.get(&norm(path))
.cloned()
.ok_or_else(|| crate::error::Error::other(format!("{path}: not found")))
}
async fn write_atomic(&self, path: &str, bytes: &[u8]) -> LhResult<()> {
self.files.lock().unwrap().insert(norm(path), bytes.to_vec());
Ok(())
}
async fn metadata(&self, path: &str) -> LhResult<Option<Metadata>> {
let p = norm(path);
if self.files.lock().unwrap().contains_key(&p) {
return Ok(Some(Metadata { kind: EntryKind::File, size: 0 }));
}
if self.is_dir(&p) {
return Ok(Some(Metadata { kind: EntryKind::Directory, size: 0 }));
}
Ok(None)
}
async fn read_dir(&self, path: &str) -> LhResult<Vec<DirEntry>> {
let dir = norm(path);
let prefix = if dir == "/" { "/".to_string() } else { format!("{dir}/") };
let mut names: BTreeMap<String, EntryKind> = BTreeMap::new();
for key in self.files.lock().unwrap().keys() {
if let Some(rest) = key.strip_prefix(&prefix) {
if rest.is_empty() {
continue;
}
match rest.split_once('/') {
Some((head, _)) => {
names.insert(head.to_string(), EntryKind::Directory);
}
None => {
names.entry(rest.to_string()).or_insert(EntryKind::File);
}
}
}
}
Ok(names
.into_iter()
.map(|(name, kind)| DirEntry { name, kind, size: None })
.collect())
}
async fn walk(&self, path: &str, _max_depth: Option<usize>) -> LhResult<Vec<WalkEntry>> {
let root = norm(path);
let prefix = if root == "/" { "/".to_string() } else { format!("{root}/") };
let mut out = Vec::new();
let mut seen_dirs: std::collections::BTreeSet<String> = Default::default();
for key in self.files.lock().unwrap().keys() {
if root != "/" && !key.starts_with(&prefix) {
continue;
}
let rel = key.strip_prefix(&prefix).unwrap_or(key);
let mut acc = root.trim_end_matches('/').to_string();
let comps: Vec<&str> = rel.split('/').collect();
for (i, c) in comps.iter().enumerate() {
acc = format!("{acc}/{c}");
if i + 1 < comps.len() {
if seen_dirs.insert(acc.clone()) {
out.push(WalkEntry {
path: acc.clone(),
kind: EntryKind::Directory,
size: None,
});
}
} else {
out.push(WalkEntry { path: acc.clone(), kind: EntryKind::File, size: None });
}
}
}
Ok(out)
}
async fn delete(&self, path: &str) -> LhResult<()> {
self.files.lock().unwrap().remove(&norm(path));
Ok(())
}
}
struct TestHost {
fs: MemFs,
}
impl TestHost {
fn new(files: &[(&str, &str)]) -> Self {
Self { fs: MemFs::with(files) }
}
}
#[async_trait::async_trait(?Send)]
impl BashHost for TestHost {
fn fs(&self) -> &dyn Filesystem {
&self.fs
}
}
async fn run_ok(files: &[(&str, &str)], src: &str) -> ScriptResult {
let mut host = TestHost::new(files);
run(&mut host, src).await.expect("script should run without a BashError")
}
#[test]
fn lex_words_pipes_and_separators() {
use token::{Token, WordPart};
let toks = lexer::lex("echo hi | wc -l\nls").unwrap();
assert_eq!(toks[0], Token::Word(vec![WordPart::Lit("echo".into())]));
assert_eq!(toks[1], Token::Word(vec![WordPart::Lit("hi".into())]));
assert_eq!(toks[2], Token::Pipe);
assert!(matches!(toks[5], Token::Semi)); }
#[test]
fn lex_interpolation_and_quotes() {
use token::WordPart;
let toks = lexer::lex(r#"echo "$x.rl" '$y'"#).unwrap();
if let token::Token::Word(parts) = &toks[1] {
assert_eq!(parts, &vec![WordPart::Var("x".into()), WordPart::Lit(".rl".into())]);
} else {
panic!("expected word");
}
if let token::Token::Word(parts) = &toks[2] {
assert_eq!(parts, &vec![WordPart::Lit("$y".into())]);
} else {
panic!("expected literal $y");
}
}
#[test]
fn lex_command_substitution_is_balanced() {
use token::WordPart;
let toks = lexer::lex("x=$(echo $(ls))").unwrap();
if let token::Token::Word(parts) = &toks[0] {
assert_eq!(parts[0], WordPart::Lit("x=".into()));
assert_eq!(parts[1], WordPart::Subst("echo $(ls)".into()));
} else {
panic!("expected word");
}
}
#[test]
fn lex_rejects_unterminated_quote_and_subst() {
assert_eq!(lexer::lex("echo \"oops").unwrap_err().kind, BashErrorKind::Parse);
assert_eq!(lexer::lex("echo 'oops").unwrap_err().kind, BashErrorKind::Parse);
assert_eq!(lexer::lex("x=$(echo").unwrap_err().kind, BashErrorKind::Parse);
}
#[test]
fn lex_comment_skipped() {
let toks = lexer::lex("echo hi # this is ignored\nls").unwrap();
assert_eq!(toks.len(), 5);
}
#[test]
fn parse_assignment_and_pipeline() {
let body = parser::parse(&lexer::lex("n=$(ls | wc -l)").unwrap()).unwrap();
assert!(matches!(body[0], ast::Stmt::Assign { .. }));
}
#[test]
fn parse_if_for_while() {
let prog = "if [ 1 -eq 1 ]; then echo a; elif [ 2 -eq 2 ]; then echo b; else echo c; fi";
let body = parser::parse(&lexer::lex(prog).unwrap()).unwrap();
match &body[0] {
ast::Stmt::If { arms, otherwise } => {
assert_eq!(arms.len(), 2);
assert!(otherwise.is_some());
}
_ => panic!("expected if"),
}
assert!(matches!(
parser::parse(&lexer::lex("for x in a b c; do echo $x; done").unwrap()).unwrap()[0],
ast::Stmt::For { .. }
));
assert!(matches!(
parser::parse(&lexer::lex("while [ -n x ]; do echo y; done").unwrap()).unwrap()[0],
ast::Stmt::While { .. }
));
}
#[test]
fn parse_rejects_missing_fi() {
assert_eq!(
parser::parse(&lexer::lex("if [ 1 -eq 1 ]; then echo a").unwrap()).unwrap_err().kind,
BashErrorKind::Parse
);
let body = parser::parse(&lexer::lex("echo a echo b").unwrap()).unwrap();
match &body[0] {
ast::Stmt::Pipeline(cmds) => assert_eq!(cmds[0].args.len(), 3),
_ => panic!("expected pipeline"),
}
}
#[tokio::test]
async fn echo_and_variables() {
let r = run_ok(&[], "x=world\necho hello $x").await;
assert_eq!(r.stdout, "hello world\n");
assert_eq!(r.exit_code, 0);
}
#[tokio::test]
async fn echo_n_suppresses_newline() {
let r = run_ok(&[], "echo -n hi").await;
assert_eq!(r.stdout, "hi");
}
#[tokio::test]
async fn command_substitution_trims_trailing_newline() {
let r = run_ok(&[], "x=$(echo hi)\necho [$x]").await;
assert_eq!(r.stdout, "[hi]\n");
}
#[tokio::test]
async fn nested_substitution() {
let r = run_ok(&[], "echo $(echo $(echo deep))").await;
assert_eq!(r.stdout, "deep\n");
}
#[tokio::test]
async fn exit_status_variable() {
let r = run_ok(&[], "[ 1 -eq 2 ]\necho $?").await;
assert_eq!(r.stdout, "1\n");
let r = run_ok(&[], "echo hi\necho $?").await;
assert_eq!(r.stdout, "hi\n0\n");
}
#[tokio::test]
async fn pipe_echo_into_wc() {
let r = run_ok(&[], "echo -n abc | wc -c").await;
assert_eq!(r.stdout, "3\n");
}
#[tokio::test]
async fn pipe_three_stages() {
let files = &[("/a.rl", ""), ("/b.txt", ""), ("/c.rl", "")];
let r = run_ok(files, "ls | grep .rl | wc -l").await;
assert_eq!(r.stdout, "2\n");
}
#[tokio::test]
async fn ls_lists_sorted_names() {
let r = run_ok(&[("/b", ""), ("/a", ""), ("/sub/x", "")], "ls").await;
assert_eq!(r.stdout, "a\nb\nsub\n");
}
#[tokio::test]
async fn cd_changes_resolution() {
let files = &[("/proj/main.rl", "fn"), ("/proj/lib.rl", "fn")];
let r = run_ok(files, "cd proj\nls | wc -l").await;
assert_eq!(r.stdout, "2\n");
let r = run_ok(files, "cd proj\npwd").await;
assert_eq!(r.stdout, "/proj\n");
}
#[tokio::test]
async fn cat_concatenates() {
let r = run_ok(&[("/x.txt", "one\n"), ("/y.txt", "two\n")], "cat x.txt y.txt").await;
assert_eq!(r.stdout, "one\ntwo\n");
}
#[tokio::test]
async fn cat_missing_file_is_nonzero_not_a_basherror() {
let r = run_ok(&[], "cat nope.txt\necho done").await;
assert!(r.stderr.contains("nope.txt"));
assert!(r.stdout.contains("done"));
}
#[tokio::test]
async fn grep_filters_and_flags() {
let r = run_ok(&[], "echo -n 'foo\nbar\nFOOBAR' | grep -i foo").await;
assert_eq!(r.stdout, "foo\nFOOBAR\n");
let r = run_ok(&[], "echo -n 'a\nb\nc' | grep -v b").await;
assert_eq!(r.stdout, "a\nc\n");
let r = run_ok(&[], "echo -n 'x\nxy\nz' | grep -c x").await;
assert_eq!(r.stdout, "2\n");
let r = run_ok(&[], "echo -n 'a\nb' | grep -c z").await;
assert_eq!(r.stdout, "0\n");
assert_eq!(r.exit_code, 1);
let r = run_ok(&[], "echo -n 'a\nb' | grep -c a").await;
assert_eq!(r.stdout, "1\n");
assert_eq!(r.exit_code, 0);
}
#[tokio::test]
async fn find_name_glob_and_type() {
let files = &[("/src/a.rl", ""), ("/src/b.txt", ""), ("/src/sub/c.rl", "")];
let r = run_ok(files, "find src -name '*.rl' -type f").await;
assert_eq!(r.stdout, "src/a.rl\nsrc/sub/c.rl\n");
}
#[tokio::test]
async fn head_and_tail_limit_lines() {
let files = &[("/a", ""), ("/b", ""), ("/c", ""), ("/d", "")];
assert_eq!(run_ok(files, "ls | head -2").await.stdout, "a\nb\n");
assert_eq!(run_ok(files, "ls | head -n 3").await.stdout, "a\nb\nc\n");
assert_eq!(run_ok(files, "ls | tail -2").await.stdout, "c\nd\n");
assert_eq!(run_ok(files, "ls | tail -n 1").await.stdout, "d\n");
assert_eq!(run_ok(files, "ls | head").await.stdout, "a\nb\nc\nd\n");
assert_eq!(run_ok(files, "ls | head -9").await.stdout, "a\nb\nc\nd\n");
assert_eq!(run_ok(files, "ls | head -x\necho $?").await.stdout, "2\n");
}
#[tokio::test]
async fn wc_modes() {
let r = run_ok(&[], "echo 'a b c' | wc -w").await;
assert_eq!(r.stdout, "3\n");
let r = run_ok(&[], "echo -n 'l1\nl2' | wc -l").await;
assert_eq!(r.stdout, "2\n");
}
#[tokio::test]
async fn write_create_then_read_back() {
let mut host = TestHost::new(&[]);
let r = run(&mut host, "write notes.txt hello there\ncat notes.txt").await.unwrap();
assert_eq!(r.stdout, "hello there");
let r2 = run(&mut host, "write notes.txt again").await.unwrap();
assert!(r2.stderr.contains("already exists"));
}
#[tokio::test]
async fn mkdir_makes_a_listable_dir() {
let mut host = TestHost::new(&[]);
let r = run(&mut host, "mkdir d\nwrite d/f.txt hi\nls d").await.unwrap();
assert_eq!(r.stdout, ".keep\nf.txt\n");
}
#[tokio::test]
async fn test_string_and_int_ops() {
let r = run_ok(&[], "if [ abc = abc ]; then echo eq; fi").await;
assert_eq!(r.stdout, "eq\n");
let r = run_ok(&[], "if [ 3 -gt 2 ]; then echo big; fi").await;
assert_eq!(r.stdout, "big\n");
let r = run_ok(&[], "if [ -z '' ]; then echo empty; fi").await;
assert_eq!(r.stdout, "empty\n");
let r = run_ok(&[], "if [ x = y ]; then echo a; else echo b; fi").await;
assert_eq!(r.stdout, "b\n");
}
#[tokio::test]
async fn test_file_existence_predicates() {
let files = &[("/a.txt", "hi"), ("/sub/b.txt", "x")];
assert_eq!(run_ok(files, "if [ -e a.txt ]; then echo yes; fi").await.stdout, "yes\n");
assert_eq!(
run_ok(files, "if [ -e nope ]; then echo y; else echo no; fi").await.stdout,
"no\n"
);
assert_eq!(run_ok(files, "if [ -f a.txt ]; then echo file; fi").await.stdout, "file\n");
assert_eq!(
run_ok(files, "if [ -f sub ]; then echo y; else echo notfile; fi").await.stdout,
"notfile\n"
);
assert_eq!(run_ok(files, "if [ -d sub ]; then echo dir; fi").await.stdout, "dir\n");
assert_eq!(
run_ok(files, "if [ -d a.txt ]; then echo y; else echo notdir; fi").await.stdout,
"notdir\n"
);
}
#[tokio::test]
async fn test_f_guards_create_only_write() {
let mut host = TestHost::new(&[]);
let r = run(&mut host, "[ -f out.txt ] || write out.txt first\ncat out.txt").await.unwrap();
assert_eq!(r.stdout, "first");
let r = run(&mut host, "[ -f out.txt ] || write out.txt second\ncat out.txt").await.unwrap();
assert_eq!(r.stdout, "first");
assert!(r.stderr.is_empty(), "guarded write must not error: {:?}", r.stderr);
}
#[tokio::test]
async fn test_missing_bracket_errors_nonzero() {
let r = run_ok(&[], "[ 1 -eq 1\necho $?").await;
assert_eq!(r.stdout, "2\n");
}
#[tokio::test]
async fn for_loop_iterates_words() {
let r = run_ok(&[], "for x in a b c; do echo $x; done").await;
assert_eq!(r.stdout, "a\nb\nc\n");
}
#[tokio::test]
async fn for_loop_over_substitution() {
let files = &[("/one.rl", ""), ("/two.rl", "")];
let r = run_ok(files, "for f in $(ls); do echo got $f; done").await;
assert!(r.stdout.contains("one.rl"));
assert!(r.stdout.contains("two.rl"));
}
#[tokio::test]
async fn while_loop_with_counter_via_substitution() {
let src = "\
s=\n\
while [ $(echo -n $s | wc -c) -lt 3 ]; do\n\
s=${s}x\n\
done\n\
echo -n $s | wc -c";
let r = run_ok(&[], src).await;
assert_eq!(r.stdout, "3\n");
}
#[tokio::test]
async fn infinite_loop_is_caught_by_fuel() {
let mut host = TestHost::new(&[]);
let err = run_with_fuel(&mut host, "while true; do echo x; done", 50)
.await
.unwrap_err();
assert_eq!(err.kind, BashErrorKind::Fuel);
}
#[tokio::test]
async fn substitution_shares_the_fuel_budget() {
let mut host = TestHost::new(&[]);
let err = run_with_fuel(&mut host, "x=$(while true; do echo y; done)", 50)
.await
.unwrap_err();
assert_eq!(err.kind, BashErrorKind::Fuel);
}
#[tokio::test]
async fn end_to_end_write_then_ls_grep_wc() {
let src = "\
write report.rl 'fn frame() {}'\n\
write notes.txt hi\n\
write app.rl x\n\
n=$(ls | grep .rl | wc -l)\n\
echo \"$n cartridges\"";
let r = run_ok(&[], src).await;
assert_eq!(r.stdout, "2 cartridges\n");
assert_eq!(r.exit_code, 0);
}
#[tokio::test]
async fn unknown_command_is_127_not_a_basherror() {
let r = run_ok(&[], "frobnicate\necho $?").await;
assert_eq!(r.stdout, "127\n");
}
#[tokio::test]
async fn run_composes_a_subscript() {
let files = &[("/step.bl", "x=child\necho from $x")];
let r = run_ok(files, "out=$(run step.bl)\necho [$out] x=$x").await;
assert_eq!(r.stdout, "[from child] x=\n");
}
#[tokio::test]
async fn run_nests_script_within_script() {
let files = &[
("/a.bl", "echo a-start\nrun b.bl\necho a-end"),
("/b.bl", "echo b-inner"),
];
let r = run_ok(files, "run a.bl").await;
assert_eq!(r.stdout, "a-start\nb-inner\na-end\n");
}
#[tokio::test]
async fn run_fanout_over_discovered_scripts() {
let files = &[
("/jobs/one.bl", "echo one"),
("/jobs/two.bl", "echo two"),
];
let r = run_ok(files, "for f in $(find jobs -name '*.bl'); do run $f; done").await;
assert!(r.stdout.contains("one") && r.stdout.contains("two"), "{:?}", r.stdout);
}
#[tokio::test]
async fn run_missing_file_is_nonzero_not_fatal() {
let r = run_ok(&[], "run nope.bl\necho after=$?").await;
assert!(r.stderr.contains("nope.bl"));
assert!(r.stdout.contains("after=1"));
}
#[tokio::test]
async fn run_broken_subscript_is_nonzero_not_fatal() {
let files = &[("/bad.bl", "if [ 1 -eq 1 ]; then echo a")]; let r = run_ok(files, "run bad.bl\necho after=$?").await;
assert!(r.stdout.contains("after=2"), "{:?}", r.stdout);
}
#[tokio::test]
async fn run_self_recursion_is_bounded_by_fuel() {
let mut host = TestHost::new(&[("/self.bl", "run self.bl")]);
let err = run_with_fuel(&mut host, "run self.bl", 200).await.unwrap_err();
assert_eq!(err.kind, BashErrorKind::Fuel);
}
struct ExtHost {
fs: MemFs,
}
#[async_trait::async_trait(?Send)]
impl BashHost for ExtHost {
fn fs(&self) -> &dyn Filesystem {
&self.fs
}
async fn run_builtin(&mut self, cwd: &str, cmd: &str, args: &[String], stdin: &str) -> Output {
if cmd == "greet" {
let who = args.first().map(String::as_str).unwrap_or("world");
return Output::ok(format!("hello {who}\n"));
}
crate::bashlite::builtins::dispatch_in(&self.fs, cwd, cmd, args, stdin).await
}
}
#[tokio::test]
async fn and_or_short_circuit_basics() {
assert_eq!(run_ok(&[], "true && echo yes").await.stdout, "yes\n");
assert_eq!(run_ok(&[], "false && echo no").await.stdout, ""); assert_eq!(run_ok(&[], "false || echo fallback").await.stdout, "fallback\n");
assert_eq!(run_ok(&[], "true || echo skip").await.stdout, ""); }
#[tokio::test]
async fn and_or_mixed_chain_is_not_a_break() {
assert_eq!(
run_ok(&[], "[ 1 -eq 1 ] && echo a || echo b").await.stdout,
"a\n"
);
assert_eq!(
run_ok(&[], "[ 1 -eq 2 ] && echo a || echo b").await.stdout,
"b\n"
);
}
#[tokio::test]
async fn and_or_chains_real_commands_and_pipes() {
let mut host = TestHost::new(&[]);
let r = run(&mut host, "write f.txt hi && cat f.txt | wc -c").await.unwrap();
assert_eq!(r.stdout, "2\n");
let r = run_ok(&[], "true && false\necho $?").await;
assert_eq!(r.stdout, "1\n");
let r = run_ok(&[], "false || true\necho $?").await;
assert_eq!(r.stdout, "0\n");
}
#[test]
fn parses_and_or_and_lexer_rejects_lone_amp() {
match &parser::parse(&lexer::lex("a && b || c").unwrap()).unwrap()[0] {
ast::Stmt::AndOr { pipelines, ops } => {
assert_eq!(pipelines.len(), 3);
assert_eq!(ops, &vec![ast::ChainOp::And, ast::ChainOp::Or]);
}
_ => panic!("expected AndOr"),
}
assert_eq!(lexer::lex("sleep 1 &").unwrap_err().kind, BashErrorKind::Parse);
assert!(matches!(
parser::parse(&lexer::lex("echo hi").unwrap()).unwrap()[0],
ast::Stmt::Pipeline(_)
));
}
#[tokio::test]
async fn host_run_builtin_override_adds_a_command() {
let mut host = ExtHost { fs: MemFs::with(&[("/a.txt", "x\n")]) };
let r = run(&mut host, "greet bashlite | wc -w\nls\ncat a.txt").await.unwrap();
assert_eq!(r.stdout, "2\na.txt\nx\n"); }
}