1pub mod token;
50pub mod lexer;
52pub mod ast;
54pub mod parser;
56pub mod host;
58pub mod builtins;
60#[cfg(feature = "wallet")]
62pub mod platform;
63pub mod eval;
65
66pub use eval::{Evaluator, ScriptResult, DEFAULT_FUEL, MAX_OUTPUT_BYTES, MAX_SCRIPT_SIZE};
67pub use host::{BashHost, Output};
68
69pub fn resolve_path(cwd: &str, path: &str) -> String {
74 builtins::resolve(cwd, path)
75}
76
77#[cfg(not(target_arch = "wasm32"))]
80pub(crate) type BoxFut<'a, T> = std::pin::Pin<Box<dyn std::future::Future<Output = T> + 'a>>;
81#[cfg(target_arch = "wasm32")]
82pub(crate) type BoxFut<'a, T> = std::pin::Pin<Box<dyn std::future::Future<Output = T> + 'a>>;
83
84#[derive(Debug, Clone, PartialEq)]
89pub struct BashError {
90 pub kind: BashErrorKind,
92 pub message: String,
94}
95
96#[derive(Debug, Clone, Copy, PartialEq, Eq)]
98pub enum BashErrorKind {
99 Parse,
101 Fuel,
103 Other,
105}
106
107impl BashError {
108 pub(crate) fn parse(msg: impl Into<String>) -> Self {
109 Self { kind: BashErrorKind::Parse, message: msg.into() }
110 }
111 pub(crate) fn fuel() -> Self {
112 Self {
113 kind: BashErrorKind::Fuel,
114 message: format!(
115 "fuel exhausted (>{} commands/iterations) — likely an unbounded loop",
116 eval::DEFAULT_FUEL
117 ),
118 }
119 }
120 pub(crate) fn other(msg: impl Into<String>) -> Self {
121 Self { kind: BashErrorKind::Other, message: msg.into() }
122 }
123}
124
125impl std::fmt::Display for BashError {
126 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
127 let label = match self.kind {
128 BashErrorKind::Parse => "syntax error",
129 BashErrorKind::Fuel => "limit",
130 BashErrorKind::Other => "error",
131 };
132 write!(f, "bashlite {label}: {}", self.message)
133 }
134}
135
136impl std::error::Error for BashError {}
137
138pub async fn run<H: BashHost + ?Sized>(
141 host: &mut H,
142 source: &str,
143) -> Result<ScriptResult, BashError> {
144 run_with_fuel(host, source, eval::DEFAULT_FUEL).await
145}
146
147pub async fn run_with_fuel<H: BashHost + ?Sized>(
149 host: &mut H,
150 source: &str,
151 fuel: u64,
152) -> Result<ScriptResult, BashError> {
153 let tokens = lexer::lex(source)?;
154 let body = parser::parse(&tokens)?;
155 Evaluator::new(host).with_fuel(fuel).run(&body).await
156}
157
158#[cfg(test)]
164mod tests {
165 use super::*;
166 use crate::error::Result as LhResult;
167 use crate::filesystem::{
168 DirEntry, EntryKind, Filesystem, Metadata, WalkEntry,
169 };
170 use std::collections::BTreeMap;
171 use std::sync::Mutex;
172
173 #[derive(Debug, Default)]
177 struct MemFs {
178 files: Mutex<BTreeMap<String, Vec<u8>>>,
179 }
180
181 impl MemFs {
182 fn with(files: &[(&str, &str)]) -> Self {
183 let m = MemFs::default();
184 {
185 let mut g = m.files.lock().unwrap();
186 for (p, c) in files {
187 g.insert(norm(p), c.as_bytes().to_vec());
188 }
189 }
190 m
191 }
192 fn is_dir(&self, dir: &str) -> bool {
194 let dir = norm(dir);
195 if dir == "/" {
196 return true;
197 }
198 let prefix = format!("{dir}/");
199 self.files.lock().unwrap().keys().any(|k| k.starts_with(&prefix))
200 }
201 }
202
203 fn norm(p: &str) -> String {
204 let mut stack: Vec<&str> = Vec::new();
205 for c in p.split('/') {
206 match c {
207 "" | "." => {}
208 ".." => {
209 stack.pop();
210 }
211 x => stack.push(x),
212 }
213 }
214 if stack.is_empty() {
215 "/".to_string()
216 } else {
217 format!("/{}", stack.join("/"))
218 }
219 }
220
221 #[async_trait::async_trait]
222 impl Filesystem for MemFs {
223 async fn read(&self, path: &str) -> LhResult<Vec<u8>> {
224 self.files
225 .lock()
226 .unwrap()
227 .get(&norm(path))
228 .cloned()
229 .ok_or_else(|| crate::error::Error::other(format!("{path}: not found")))
230 }
231 async fn write_atomic(&self, path: &str, bytes: &[u8]) -> LhResult<()> {
232 self.files.lock().unwrap().insert(norm(path), bytes.to_vec());
233 Ok(())
234 }
235 async fn metadata(&self, path: &str) -> LhResult<Option<Metadata>> {
236 let p = norm(path);
237 if self.files.lock().unwrap().contains_key(&p) {
238 return Ok(Some(Metadata { kind: EntryKind::File, size: 0 }));
239 }
240 if self.is_dir(&p) {
241 return Ok(Some(Metadata { kind: EntryKind::Directory, size: 0 }));
242 }
243 Ok(None)
244 }
245 async fn read_dir(&self, path: &str) -> LhResult<Vec<DirEntry>> {
246 let dir = norm(path);
247 let prefix = if dir == "/" { "/".to_string() } else { format!("{dir}/") };
248 let mut names: BTreeMap<String, EntryKind> = BTreeMap::new();
249 for key in self.files.lock().unwrap().keys() {
250 if let Some(rest) = key.strip_prefix(&prefix) {
251 if rest.is_empty() {
252 continue;
253 }
254 match rest.split_once('/') {
255 Some((head, _)) => {
256 names.insert(head.to_string(), EntryKind::Directory);
257 }
258 None => {
259 names.entry(rest.to_string()).or_insert(EntryKind::File);
260 }
261 }
262 }
263 }
264 Ok(names
265 .into_iter()
266 .map(|(name, kind)| DirEntry { name, kind, size: None })
267 .collect())
268 }
269 async fn walk(&self, path: &str, _max_depth: Option<usize>) -> LhResult<Vec<WalkEntry>> {
270 let root = norm(path);
271 let prefix = if root == "/" { "/".to_string() } else { format!("{root}/") };
272 let mut out = Vec::new();
273 let mut seen_dirs: std::collections::BTreeSet<String> = Default::default();
274 for key in self.files.lock().unwrap().keys() {
275 if root != "/" && !key.starts_with(&prefix) {
276 continue;
277 }
278 let rel = key.strip_prefix(&prefix).unwrap_or(key);
280 let mut acc = root.trim_end_matches('/').to_string();
281 let comps: Vec<&str> = rel.split('/').collect();
282 for (i, c) in comps.iter().enumerate() {
283 acc = format!("{acc}/{c}");
284 if i + 1 < comps.len() {
285 if seen_dirs.insert(acc.clone()) {
286 out.push(WalkEntry {
287 path: acc.clone(),
288 kind: EntryKind::Directory,
289 size: None,
290 });
291 }
292 } else {
293 out.push(WalkEntry { path: acc.clone(), kind: EntryKind::File, size: None });
294 }
295 }
296 }
297 Ok(out)
298 }
299 async fn delete(&self, path: &str) -> LhResult<()> {
300 self.files.lock().unwrap().remove(&norm(path));
301 Ok(())
302 }
303 }
304
305 struct TestHost {
307 fs: MemFs,
308 }
309 impl TestHost {
310 fn new(files: &[(&str, &str)]) -> Self {
311 Self { fs: MemFs::with(files) }
312 }
313 }
314 #[async_trait::async_trait(?Send)]
315 impl BashHost for TestHost {
316 fn fs(&self) -> &dyn Filesystem {
317 &self.fs
318 }
319 }
320
321 async fn run_ok(files: &[(&str, &str)], src: &str) -> ScriptResult {
323 let mut host = TestHost::new(files);
324 run(&mut host, src).await.expect("script should run without a BashError")
325 }
326
327 #[test]
330 fn lex_words_pipes_and_separators() {
331 use token::{Token, WordPart};
332 let toks = lexer::lex("echo hi | wc -l\nls").unwrap();
333 assert_eq!(toks[0], Token::Word(vec![WordPart::Lit("echo".into())]));
334 assert_eq!(toks[1], Token::Word(vec![WordPart::Lit("hi".into())]));
335 assert_eq!(toks[2], Token::Pipe);
336 assert!(matches!(toks[5], Token::Semi)); }
338
339 #[test]
340 fn lex_interpolation_and_quotes() {
341 use token::WordPart;
342 let toks = lexer::lex(r#"echo "$x.rl" '$y'"#).unwrap();
344 if let token::Token::Word(parts) = &toks[1] {
345 assert_eq!(parts, &vec![WordPart::Var("x".into()), WordPart::Lit(".rl".into())]);
346 } else {
347 panic!("expected word");
348 }
349 if let token::Token::Word(parts) = &toks[2] {
350 assert_eq!(parts, &vec![WordPart::Lit("$y".into())]);
351 } else {
352 panic!("expected literal $y");
353 }
354 }
355
356 #[test]
357 fn lex_command_substitution_is_balanced() {
358 use token::WordPart;
359 let toks = lexer::lex("x=$(echo $(ls))").unwrap();
360 if let token::Token::Word(parts) = &toks[0] {
362 assert_eq!(parts[0], WordPart::Lit("x=".into()));
363 assert_eq!(parts[1], WordPart::Subst("echo $(ls)".into()));
364 } else {
365 panic!("expected word");
366 }
367 }
368
369 #[test]
370 fn lex_rejects_unterminated_quote_and_subst() {
371 assert_eq!(lexer::lex("echo \"oops").unwrap_err().kind, BashErrorKind::Parse);
372 assert_eq!(lexer::lex("echo 'oops").unwrap_err().kind, BashErrorKind::Parse);
373 assert_eq!(lexer::lex("x=$(echo").unwrap_err().kind, BashErrorKind::Parse);
374 }
375
376 #[tokio::test]
379 async fn lex_rejects_redirection_operators() {
380 for src in ["echo hi > out.txt", "echo hi >> out.txt", "cat < in.txt", "echo hi>out"] {
381 let err = lexer::lex(src).unwrap_err();
382 assert_eq!(err.kind, BashErrorKind::Parse, "{src}");
383 assert!(err.message.contains("redirection"), "{}", err.message);
384 }
385 assert_eq!(run_ok(&[], r#"echo "a > b" '<'"#).await.stdout, "a > b <\n");
386 }
387
388 #[tokio::test]
392 async fn unicode_round_trips_through_echo_and_files() {
393 let r = run_ok(&[], "echo \"🦀 héllo ワールド\" '🦀' na\\ïve").await;
394 assert_eq!(r.stdout, "🦀 héllo ワールド 🦀 naïve\n");
395 let r = run_ok(&[], "write /héllo-🦀.txt こんにちは\ncat /héllo-🦀.txt\nls /").await;
397 assert_eq!(r.exit_code, 0, "{}", r.stderr);
398 assert!(r.stdout.contains("こんにちは"), "{}", r.stdout);
399 assert!(r.stdout.contains("héllo-🦀.txt"), "{}", r.stdout);
400 }
401
402 #[test]
403 fn lex_comment_skipped() {
404 let toks = lexer::lex("echo hi # this is ignored\nls").unwrap();
405 assert_eq!(toks.len(), 5);
407 }
408
409 #[test]
412 fn parse_assignment_and_pipeline() {
413 let body = parser::parse(&lexer::lex("n=$(ls | wc -l)").unwrap()).unwrap();
414 assert!(matches!(body[0], ast::Stmt::Assign { .. }));
415 }
416
417 #[test]
418 fn parse_if_for_while() {
419 let prog = "if [ 1 -eq 1 ]; then echo a; elif [ 2 -eq 2 ]; then echo b; else echo c; fi";
420 let body = parser::parse(&lexer::lex(prog).unwrap()).unwrap();
421 match &body[0] {
422 ast::Stmt::If { arms, otherwise } => {
423 assert_eq!(arms.len(), 2);
424 assert!(otherwise.is_some());
425 }
426 _ => panic!("expected if"),
427 }
428 assert!(matches!(
429 parser::parse(&lexer::lex("for x in a b c; do echo $x; done").unwrap()).unwrap()[0],
430 ast::Stmt::For { .. }
431 ));
432 assert!(matches!(
433 parser::parse(&lexer::lex("while [ -n x ]; do echo y; done").unwrap()).unwrap()[0],
434 ast::Stmt::While { .. }
435 ));
436 }
437
438 #[test]
439 fn parse_rejects_missing_fi() {
440 assert_eq!(
442 parser::parse(&lexer::lex("if [ 1 -eq 1 ]; then echo a").unwrap()).unwrap_err().kind,
443 BashErrorKind::Parse
444 );
445 let body = parser::parse(&lexer::lex("echo a echo b").unwrap()).unwrap();
448 match &body[0] {
449 ast::Stmt::Pipeline(cmds) => assert_eq!(cmds[0].args.len(), 3),
450 _ => panic!("expected pipeline"),
451 }
452 }
453
454 #[tokio::test]
457 async fn echo_and_variables() {
458 let r = run_ok(&[], "x=world\necho hello $x").await;
459 assert_eq!(r.stdout, "hello world\n");
460 assert_eq!(r.exit_code, 0);
461 }
462
463 #[tokio::test]
464 async fn echo_n_suppresses_newline() {
465 let r = run_ok(&[], "echo -n hi").await;
466 assert_eq!(r.stdout, "hi");
467 }
468
469 #[tokio::test]
470 async fn command_substitution_trims_trailing_newline() {
471 let r = run_ok(&[], "x=$(echo hi)\necho [$x]").await;
472 assert_eq!(r.stdout, "[hi]\n");
473 }
474
475 #[tokio::test]
476 async fn nested_substitution() {
477 let r = run_ok(&[], "echo $(echo $(echo deep))").await;
478 assert_eq!(r.stdout, "deep\n");
479 }
480
481 #[tokio::test]
482 async fn exit_status_variable() {
483 let r = run_ok(&[], "[ 1 -eq 2 ]\necho $?").await;
485 assert_eq!(r.stdout, "1\n");
486 let r = run_ok(&[], "echo hi\necho $?").await;
488 assert_eq!(r.stdout, "hi\n0\n");
489 }
490
491 #[tokio::test]
494 async fn pipe_echo_into_wc() {
495 let r = run_ok(&[], "echo -n abc | wc -c").await;
496 assert_eq!(r.stdout, "3\n");
497 }
498
499 #[tokio::test]
500 async fn pipe_three_stages() {
501 let files = &[("/a.rl", ""), ("/b.txt", ""), ("/c.rl", "")];
502 let r = run_ok(files, "ls | grep .rl | wc -l").await;
504 assert_eq!(r.stdout, "2\n");
505 }
506
507 #[tokio::test]
510 async fn ls_lists_sorted_names() {
511 let r = run_ok(&[("/b", ""), ("/a", ""), ("/sub/x", "")], "ls").await;
512 assert_eq!(r.stdout, "a\nb\nsub\n");
513 }
514
515 #[tokio::test]
516 async fn cd_changes_resolution() {
517 let files = &[("/proj/main.rl", "fn"), ("/proj/lib.rl", "fn")];
518 let r = run_ok(files, "cd proj\nls | wc -l").await;
519 assert_eq!(r.stdout, "2\n");
520 let r = run_ok(files, "cd proj\npwd").await;
522 assert_eq!(r.stdout, "/proj\n");
523 }
524
525 #[tokio::test]
526 async fn cat_concatenates() {
527 let r = run_ok(&[("/x.txt", "one\n"), ("/y.txt", "two\n")], "cat x.txt y.txt").await;
528 assert_eq!(r.stdout, "one\ntwo\n");
529 }
530
531 #[tokio::test]
532 async fn cat_missing_file_is_nonzero_not_a_basherror() {
533 let r = run_ok(&[], "cat nope.txt\necho done").await;
534 assert!(r.stderr.contains("nope.txt"));
536 assert!(r.stdout.contains("done"));
537 }
538
539 #[tokio::test]
540 async fn grep_filters_and_flags() {
541 let r = run_ok(&[], "echo -n 'foo\nbar\nFOOBAR' | grep -i foo").await;
542 assert_eq!(r.stdout, "foo\nFOOBAR\n");
543 let r = run_ok(&[], "echo -n 'a\nb\nc' | grep -v b").await;
545 assert_eq!(r.stdout, "a\nc\n");
546 let r = run_ok(&[], "echo -n 'x\nxy\nz' | grep -c x").await;
548 assert_eq!(r.stdout, "2\n");
549 let r = run_ok(&[], "echo -n 'a\nb' | grep -c z").await;
553 assert_eq!(r.stdout, "0\n");
554 assert_eq!(r.exit_code, 1);
555 let r = run_ok(&[], "echo -n 'a\nb' | grep -c a").await;
557 assert_eq!(r.stdout, "1\n");
558 assert_eq!(r.exit_code, 0);
559 }
560
561 #[tokio::test]
562 async fn find_name_glob_and_type() {
563 let files = &[("/src/a.rl", ""), ("/src/b.txt", ""), ("/src/sub/c.rl", "")];
564 let r = run_ok(files, "find src -name '*.rl' -type f").await;
565 assert_eq!(r.stdout, "src/a.rl\nsrc/sub/c.rl\n");
567 }
568
569 #[tokio::test]
570 async fn head_and_tail_limit_lines() {
571 let files = &[("/a", ""), ("/b", ""), ("/c", ""), ("/d", "")];
573 assert_eq!(run_ok(files, "ls | head -2").await.stdout, "a\nb\n");
574 assert_eq!(run_ok(files, "ls | head -n 3").await.stdout, "a\nb\nc\n");
575 assert_eq!(run_ok(files, "ls | tail -2").await.stdout, "c\nd\n");
576 assert_eq!(run_ok(files, "ls | tail -n 1").await.stdout, "d\n");
577 assert_eq!(run_ok(files, "ls | head").await.stdout, "a\nb\nc\nd\n");
579 assert_eq!(run_ok(files, "ls | head -9").await.stdout, "a\nb\nc\nd\n");
580 assert_eq!(run_ok(files, "ls | head -x\necho $?").await.stdout, "2\n");
582 }
583
584 #[tokio::test]
585 async fn wc_modes() {
586 let r = run_ok(&[], "echo 'a b c' | wc -w").await;
587 assert_eq!(r.stdout, "3\n");
588 let r = run_ok(&[], "echo -n 'l1\nl2' | wc -l").await;
589 assert_eq!(r.stdout, "2\n");
590 }
591
592 #[tokio::test]
593 async fn write_create_then_read_back() {
594 let mut host = TestHost::new(&[]);
595 let r = run(&mut host, "write notes.txt hello there\ncat notes.txt").await.unwrap();
596 assert_eq!(r.stdout, "hello there");
597 let r2 = run(&mut host, "write notes.txt again").await.unwrap();
599 assert!(r2.stderr.contains("already exists"));
600 }
601
602 #[tokio::test]
603 async fn mkdir_makes_a_listable_dir() {
604 let mut host = TestHost::new(&[]);
605 let r = run(&mut host, "mkdir d\nwrite d/f.txt hi\nls d").await.unwrap();
606 assert_eq!(r.stdout, ".keep\nf.txt\n");
607 }
608
609 #[tokio::test]
612 async fn test_string_and_int_ops() {
613 let r = run_ok(&[], "if [ abc = abc ]; then echo eq; fi").await;
614 assert_eq!(r.stdout, "eq\n");
615 let r = run_ok(&[], "if [ 3 -gt 2 ]; then echo big; fi").await;
616 assert_eq!(r.stdout, "big\n");
617 let r = run_ok(&[], "if [ -z '' ]; then echo empty; fi").await;
618 assert_eq!(r.stdout, "empty\n");
619 let r = run_ok(&[], "if [ x = y ]; then echo a; else echo b; fi").await;
621 assert_eq!(r.stdout, "b\n");
622 }
623
624 #[tokio::test]
625 async fn test_file_existence_predicates() {
626 let files = &[("/a.txt", "hi"), ("/sub/b.txt", "x")];
627 assert_eq!(run_ok(files, "if [ -e a.txt ]; then echo yes; fi").await.stdout, "yes\n");
629 assert_eq!(
630 run_ok(files, "if [ -e nope ]; then echo y; else echo no; fi").await.stdout,
631 "no\n"
632 );
633 assert_eq!(run_ok(files, "if [ -f a.txt ]; then echo file; fi").await.stdout, "file\n");
635 assert_eq!(
636 run_ok(files, "if [ -f sub ]; then echo y; else echo notfile; fi").await.stdout,
637 "notfile\n"
638 );
639 assert_eq!(run_ok(files, "if [ -d sub ]; then echo dir; fi").await.stdout, "dir\n");
641 assert_eq!(
642 run_ok(files, "if [ -d a.txt ]; then echo y; else echo notdir; fi").await.stdout,
643 "notdir\n"
644 );
645 }
646
647 #[tokio::test]
648 async fn test_f_guards_create_only_write() {
649 let mut host = TestHost::new(&[]);
652 let r = run(&mut host, "[ -f out.txt ] || write out.txt first\ncat out.txt").await.unwrap();
653 assert_eq!(r.stdout, "first");
654 let r = run(&mut host, "[ -f out.txt ] || write out.txt second\ncat out.txt").await.unwrap();
657 assert_eq!(r.stdout, "first");
658 assert!(r.stderr.is_empty(), "guarded write must not error: {:?}", r.stderr);
659 }
660
661 #[tokio::test]
662 async fn test_missing_bracket_errors_nonzero() {
663 let r = run_ok(&[], "[ 1 -eq 1\necho $?").await;
665 assert_eq!(r.stdout, "2\n");
666 }
667
668 #[tokio::test]
671 async fn for_loop_iterates_words() {
672 let r = run_ok(&[], "for x in a b c; do echo $x; done").await;
673 assert_eq!(r.stdout, "a\nb\nc\n");
674 }
675
676 #[tokio::test]
677 async fn for_loop_over_substitution() {
678 let files = &[("/one.rl", ""), ("/two.rl", "")];
679 let r = run_ok(files, "for f in $(ls); do echo got $f; done").await;
683 assert!(r.stdout.contains("one.rl"));
684 assert!(r.stdout.contains("two.rl"));
685 }
686
687 #[tokio::test]
688 async fn while_loop_with_counter_via_substitution() {
689 let src = "\
692 s=\n\
693 while [ $(echo -n $s | wc -c) -lt 3 ]; do\n\
694 s=${s}x\n\
695 done\n\
696 echo -n $s | wc -c";
697 let r = run_ok(&[], src).await;
698 assert_eq!(r.stdout, "3\n");
699 }
700
701 #[tokio::test]
704 async fn infinite_loop_is_caught_by_fuel() {
705 let mut host = TestHost::new(&[]);
706 let err = run_with_fuel(&mut host, "while true; do echo x; done", 50)
707 .await
708 .unwrap_err();
709 assert_eq!(err.kind, BashErrorKind::Fuel);
710 }
711
712 #[tokio::test]
713 async fn substitution_shares_the_fuel_budget() {
714 let mut host = TestHost::new(&[]);
716 let err = run_with_fuel(&mut host, "x=$(while true; do echo y; done)", 50)
717 .await
718 .unwrap_err();
719 assert_eq!(err.kind, BashErrorKind::Fuel);
720 }
721
722 #[tokio::test]
725 async fn end_to_end_write_then_ls_grep_wc() {
726 let src = "\
728 write report.rl 'fn frame() {}'\n\
729 write notes.txt hi\n\
730 write app.rl x\n\
731 n=$(ls | grep .rl | wc -l)\n\
732 echo \"$n cartridges\"";
733 let r = run_ok(&[], src).await;
734 assert_eq!(r.stdout, "2 cartridges\n");
735 assert_eq!(r.exit_code, 0);
736 }
737
738 #[tokio::test]
739 async fn unknown_command_is_127_not_a_basherror() {
740 let r = run_ok(&[], "frobnicate\necho $?").await;
741 assert_eq!(r.stdout, "127\n");
742 }
743
744 #[tokio::test]
747 async fn run_composes_a_subscript() {
748 let files = &[("/step.bl", "x=child\necho from $x")];
751 let r = run_ok(files, "out=$(run step.bl)\necho [$out] x=$x").await;
752 assert_eq!(r.stdout, "[from child] x=\n");
754 }
755
756 #[tokio::test]
757 async fn run_nests_script_within_script() {
758 let files = &[
760 ("/a.bl", "echo a-start\nrun b.bl\necho a-end"),
761 ("/b.bl", "echo b-inner"),
762 ];
763 let r = run_ok(files, "run a.bl").await;
764 assert_eq!(r.stdout, "a-start\nb-inner\na-end\n");
765 }
766
767 #[tokio::test]
768 async fn run_fanout_over_discovered_scripts() {
769 let files = &[
772 ("/jobs/one.bl", "echo one"),
773 ("/jobs/two.bl", "echo two"),
774 ];
775 let r = run_ok(files, "for f in $(find jobs -name '*.bl'); do run $f; done").await;
776 assert!(r.stdout.contains("one") && r.stdout.contains("two"), "{:?}", r.stdout);
779 }
780
781 #[tokio::test]
782 async fn run_missing_file_is_nonzero_not_fatal() {
783 let r = run_ok(&[], "run nope.bl\necho after=$?").await;
784 assert!(r.stderr.contains("nope.bl"));
785 assert!(r.stdout.contains("after=1"));
786 }
787
788 #[tokio::test]
789 async fn run_broken_subscript_is_nonzero_not_fatal() {
790 let files = &[("/bad.bl", "if [ 1 -eq 1 ]; then echo a")]; let r = run_ok(files, "run bad.bl\necho after=$?").await;
793 assert!(r.stdout.contains("after=2"), "{:?}", r.stdout);
794 }
795
796 #[tokio::test]
797 async fn run_oversized_script_is_rejected_small_one_runs() {
798 let big = "#".repeat(MAX_SCRIPT_SIZE + 1);
801 let files = &[("/big.bl", big.as_str()), ("/small.bl", "echo ok")];
802 let r = run_ok(files, "run big.bl\necho after=$?").await;
803 assert!(r.stderr.contains("exceeds"), "{:?}", r.stderr);
804 assert!(r.stdout.contains("after=1"), "{:?}", r.stdout);
805
806 let r2 = run_ok(files, "run small.bl").await;
807 assert_eq!(r2.exit_code, 0);
808 assert!(r2.stdout.contains("ok"), "{:?}", r2.stdout);
809 }
810
811 #[tokio::test]
812 async fn run_self_recursion_is_bounded_by_fuel() {
813 let mut host = TestHost::new(&[("/self.bl", "run self.bl")]);
816 let err = run_with_fuel(&mut host, "run self.bl", 200).await.unwrap_err();
817 assert_eq!(err.kind, BashErrorKind::Fuel);
818 }
819
820 #[tokio::test]
821 async fn stderr_is_output_capped() {
822 let mut host = TestHost::new(&[]);
825 let err = run_with_fuel(&mut host, "while [ 1 = 1 ]; do cat nope.txt; done", 5_000_000)
826 .await
827 .expect_err("stderr flood must be capped");
828 assert_eq!(err.kind, BashErrorKind::Other);
829 assert!(err.message.contains("output exceeded"));
830 }
831
832 struct ExtHost {
837 fs: MemFs,
838 }
839 #[async_trait::async_trait(?Send)]
840 impl BashHost for ExtHost {
841 fn fs(&self) -> &dyn Filesystem {
842 &self.fs
843 }
844 async fn run_builtin(&mut self, cwd: &str, cmd: &str, args: &[String], stdin: &str) -> Output {
845 if cmd == "greet" {
846 let who = args.first().map(String::as_str).unwrap_or("world");
847 return Output::ok(format!("hello {who}\n"));
848 }
849 crate::bashlite::builtins::dispatch_in(&self.fs, cwd, cmd, args, stdin).await
850 }
851 }
852
853 #[tokio::test]
856 async fn and_or_short_circuit_basics() {
857 assert_eq!(run_ok(&[], "true && echo yes").await.stdout, "yes\n");
859 assert_eq!(run_ok(&[], "false && echo no").await.stdout, ""); assert_eq!(run_ok(&[], "false || echo fallback").await.stdout, "fallback\n");
861 assert_eq!(run_ok(&[], "true || echo skip").await.stdout, ""); }
863
864 #[tokio::test]
865 async fn and_or_mixed_chain_is_not_a_break() {
866 assert_eq!(
869 run_ok(&[], "[ 1 -eq 1 ] && echo a || echo b").await.stdout,
870 "a\n"
871 );
872 assert_eq!(
873 run_ok(&[], "[ 1 -eq 2 ] && echo a || echo b").await.stdout,
874 "b\n"
875 );
876 }
877
878 #[tokio::test]
879 async fn and_or_chains_real_commands_and_pipes() {
880 let mut host = TestHost::new(&[]);
881 let r = run(&mut host, "write f.txt hi && cat f.txt | wc -c").await.unwrap();
883 assert_eq!(r.stdout, "2\n");
884 let r = run_ok(&[], "true && false\necho $?").await;
886 assert_eq!(r.stdout, "1\n");
887 let r = run_ok(&[], "false || true\necho $?").await;
888 assert_eq!(r.stdout, "0\n");
889 }
890
891 #[test]
892 fn parses_and_or_and_lexer_rejects_lone_amp() {
893 match &parser::parse(&lexer::lex("a && b || c").unwrap()).unwrap()[0] {
895 ast::Stmt::AndOr { pipelines, ops } => {
896 assert_eq!(pipelines.len(), 3);
897 assert_eq!(ops, &vec![ast::ChainOp::And, ast::ChainOp::Or]);
898 }
899 _ => panic!("expected AndOr"),
900 }
901 assert_eq!(lexer::lex("sleep 1 &").unwrap_err().kind, BashErrorKind::Parse);
903 assert!(matches!(
905 parser::parse(&lexer::lex("echo hi").unwrap()).unwrap()[0],
906 ast::Stmt::Pipeline(_)
907 ));
908 }
909
910 #[tokio::test]
911 async fn host_run_builtin_override_adds_a_command() {
912 let mut host = ExtHost { fs: MemFs::with(&[("/a.txt", "x\n")]) };
913 let r = run(&mut host, "greet bashlite | wc -w\nls\ncat a.txt").await.unwrap();
916 assert_eq!(r.stdout, "2\na.txt\nx\n"); }
918}