Skip to main content

localharness/bashlite/
mod.rs

1//! bashlite — a tiny, deterministic, TOTAL shell that scripts the platform's
2//! filesystem in ONE pass (see `design/bashlite.md`).
3//!
4//! The cost unlock: an agent doing a multi-step fs chore today burns one LLM
5//! round (full context + ~70 tool schemas) per step. bashlite collapses the
6//! chore into a single `execute_script` tool call — the platform runs the whole
7//! script locally and only the final stdout returns to the model.
8//!
9//! Shape (mirrors `rustlite/`): lexer → parser → eval over a [`BashHost`] trait,
10//! so the whole interpreter is native-testable (`cargo test`) and the
11//! browser/CLI just supply the host. NO eval, NO process spawning, NO network.
12//!
13//! ## v1 language subset (this module)
14//!
15//! - Variables: `x=value`, `x=$(cmd)`; `$x` / `${x}` interpolation; `$?` exit.
16//! - Pipes: `a | b | c` (stdout → stdin).
17//! - Short-circuit chains: `a && b` (run `b` iff `a` exited 0), `a || b` (iff
18//!   nonzero); mixed `cond && a || b` is the ternary (skip, not break).
19//! - Control flow: `if/elif/else/fi`, `for NAME in …; do … done`,
20//!   `while …; do … done`.
21//! - Tests: `[ … ]` / `test …` (string `=`/`!=`/`-z`/`-n`, int `-eq`/`-lt`/…,
22//!   file `-e`/`-f`/`-d PATH`).
23//! - Command substitution: `$(…)` (nested, subshell vars, shared fuel).
24//! - Builtins (fs): `echo`, `cd`, `pwd`, `ls`, `cat`, `grep`, `find`,
25//!   `wc`, `head`/`tail` (first/last N stdin lines), `mkdir`,
26//!   `write`/`create` (CREATE-only), `true`/`false`.
27//! - **Composition (`run`/`source`/`.`)**: execute another `.bl` script in a
28//!   nested evaluator (shared fs + fuel) — a script is a composition of scripts.
29//!   FRACTAL: the sub-script can itself `run` more, bounded by the shared fuel.
30//! - **Host extension**: a [`BashHost`] adds commands by overriding `run_builtin`
31//!   (e.g. the `lh-*` platform reads in [`platform`]) — the evaluator routes every
32//!   non-control command through it, so "localharnesslite" is just more commands.
33//! - `for NAME in $(…)` FIELD-SPLITS on whitespace (the fan-out pattern).
34//! - Quoting: `'single'` literal, `"double"` interpolating, `\` escape.
35//! - BOUNDED: a fuel budget caps total commands + loop iterations so a
36//!   `while true` (or `run`-recursion) can't hang; output is byte-capped.
37//!
38//! ## Deferred to v2 (intentionally NOT in v1 — see `design/bashlite.md`)
39//!
40//! - Value-MOVING `lh-*` commands (`lh-send`, `lh-create`, …) + the
41//!   dry-run-manifest confirm flow. (Read-only `lh-*` ship in [`platform`].)
42//! - `break` / `continue` / `return`, functions,
43//!   here-docs, redirection (`>`/`>>`/`<` — REJECTED with a parse error, never
44//!   lexed as literal args), real regex grep, file-arg `wc`,
45//!   field splitting of unquoted expansions OUTSIDE `for` items, globbing,
46//!   arithmetic `$(( ))`.
47
48/// Token kinds.
49pub mod token;
50/// Shell-shaped byte lexer.
51pub mod lexer;
52/// Script AST.
53pub mod ast;
54/// Recursive-descent parser.
55pub mod parser;
56/// The [`BashHost`] seam + [`Output`].
57pub mod host;
58/// FS-only builtin commands over [`crate::filesystem::Filesystem`].
59pub mod builtins;
60/// Read-only `lh-*` platform commands (localharnesslite). Needs `wallet`.
61#[cfg(feature = "wallet")]
62pub mod platform;
63/// Bounded, total evaluator.
64pub mod eval;
65
66pub use eval::{Evaluator, ScriptResult, DEFAULT_FUEL, MAX_OUTPUT_BYTES, MAX_SCRIPT_SIZE};
67pub use host::{BashHost, Output};
68
69/// Resolve `path` against `cwd` into a normalized sandbox path the way the fs
70/// builtins do — so a host that reads a file ITSELF (e.g. `lh-publish`'s source
71/// arg, before [`platform::dispatch_write`]) resolves it identically. Absolute
72/// `path` ignores `cwd`; `.`/`..` collapse; `..` clamps to root (no escape).
73pub fn resolve_path(cwd: &str, path: &str) -> String {
74    builtins::resolve(cwd, path)
75}
76
77/// A boxed future alias for the evaluator's async recursion. `?Send` on wasm to
78/// match the rest of the crate's single-threaded executor.
79#[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/// An error from any stage (lex, parse, or a fatal runtime condition like fuel
85/// exhaustion). A command FAILING (nonzero exit) is NOT an error — that's a
86/// normal [`Output`] with a nonzero code that the script can branch on. Only a
87/// malformed script or a runaway guard produces a `BashError`.
88#[derive(Debug, Clone, PartialEq)]
89pub struct BashError {
90    /// What went wrong (`parse`, `fuel`, or `other`).
91    pub kind: BashErrorKind,
92    /// Human-readable message.
93    pub message: String,
94}
95
96/// The category of a [`BashError`].
97#[derive(Debug, Clone, Copy, PartialEq, Eq)]
98pub enum BashErrorKind {
99    /// A lex or parse failure (malformed script).
100    Parse,
101    /// The fuel budget was exhausted (likely a runaway loop).
102    Fuel,
103    /// A fatal runtime condition (e.g. output cap exceeded).
104    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
138/// Compile + run `source` against `host` with the default fuel budget,
139/// returning the accumulated stdout/stderr/exit code.
140pub 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
147/// Like [`run`] but with an explicit fuel budget (commands + loop iterations).
148pub 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// ===========================================================================
159// Native-testable core: an in-memory filesystem + host, and a thorough suite
160// covering the lexer, parser, eval, every builtin, fuel, substitution, pipes.
161// ===========================================================================
162
163#[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    /// A trivial in-memory filesystem: a flat path→bytes map. Directories are
174    /// implicit (any prefix of a stored file path). Enough to exercise every
175    /// builtin without touching the disk.
176    #[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        /// Does any stored file live under `dir` (making it an implicit dir)?
193        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                // Emit each intermediate directory once.
279                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    /// Test host: just wraps a [`MemFs`] and uses the default builtin dispatch.
306    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    /// Run `src` against a fresh host seeded with `files`; assert no BashError.
322    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    // -------- lexer --------
328
329    #[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)); // newline → Semi
337    }
338
339    #[test]
340    fn lex_interpolation_and_quotes() {
341        use token::WordPart;
342        // "$x.rl" → Var(x) + Lit(.rl); single quotes are literal.
343        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        // The assignment word is `x=` Lit + Subst("echo $(ls)").
361        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    /// Telemetry #50: unsupported redirection must be a PARSE ERROR, not literal
377    /// echo args (a silent no-op "write"). Quoted/escaped `>` stays a literal.
378    #[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    /// Telemetry #49: non-ASCII must round-trip through the lexer — byte-casting
389    /// (`c as char`) Latin-1-mangled every multi-byte char (mojibake output +
390    /// invalid unicode filenames on Windows).
391    #[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        // A unicode filename written then read back stays byte-identical.
396        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        // echo hi ; ls Eof
406        assert_eq!(toks.len(), 5);
407    }
408
409    // -------- parser --------
410
411    #[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        // An unterminated `if` is a syntax error.
441        assert_eq!(
442            parser::parse(&lexer::lex("if [ 1 -eq 1 ]; then echo a").unwrap()).unwrap_err().kind,
443            BashErrorKind::Parse
444        );
445        // `echo a echo b` is VALID — one command, three args (no run-on, since a
446        // command greedily eats words until a separator/pipe/keyword).
447        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    // -------- eval: echo / vars / substitution --------
455
456    #[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        // `[ 1 -eq 2 ]` is false (exit 1); $? reflects it.
484        let r = run_ok(&[], "[ 1 -eq 2 ]\necho $?").await;
485        assert_eq!(r.stdout, "1\n");
486        // A successful command resets $? to 0.
487        let r = run_ok(&[], "echo hi\necho $?").await;
488        assert_eq!(r.stdout, "hi\n0\n");
489    }
490
491    // -------- eval: pipes --------
492
493    #[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        // ls | grep .rl | wc -l  => 2 (.rl files)
503        let r = run_ok(files, "ls | grep .rl | wc -l").await;
504        assert_eq!(r.stdout, "2\n");
505    }
506
507    // -------- builtins: ls / cat / find / grep / wc / mkdir / write --------
508
509    #[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        // pwd reflects the cd.
521        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        // The cat fails (stderr + nonzero) but the SCRIPT continues.
535        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        // -v inverts.
544        let r = run_ok(&[], "echo -n 'a\nb\nc' | grep -v b").await;
545        assert_eq!(r.stdout, "a\nc\n");
546        // -c counts.
547        let r = run_ok(&[], "echo -n 'x\nxy\nz' | grep -c x").await;
548        assert_eq!(r.stdout, "2\n");
549        // -c preserves grep's match-based exit: a zero count is STILL exit 1
550        // (POSIX — -c changes the output, not the exit status), so
551        // `grep -c x && …` doesn't fire on no matches.
552        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        // A non-zero count is exit 0.
556        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        // sorted: src/a.rl, src/sub/c.rl
566        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        // `ls` emits one name per line (sorted a,b,c,d) — real newlines to slice.
572        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        // Default 10, and n > available, both pass everything through.
578        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        // A bad count is a usage error (exit 2), not a panic.
581        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        // Re-creating refuses (create-only).
598        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    // -------- tests `[ ... ]` --------
610
611    #[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        // A false branch falls through to else.
620        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        // -e: exists (file OR dir).
628        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        // -f: a regular file (a directory is NOT -f).
634        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        // -d: a directory (a file is NOT -d).
640        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        // The idiom file tests + `||` unlock: "create only if missing" — safe with
650        // the create-only `write` (which otherwise errors on an existing file).
651        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        // Re-run: out.txt now exists → the guard is true → write is SKIPPED (no
655        // "already exists" error), content unchanged.
656        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        // `[ 1 -eq 1` with no `]` → exit 2, not a panic / BashError.
664        let r = run_ok(&[], "[ 1 -eq 1\necho $?").await;
665        assert_eq!(r.stdout, "2\n");
666    }
667
668    // -------- control flow --------
669
670    #[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        // Each filename echoed (substitution of ls gives them whitespace-joined,
680        // but v1 has no field splitting — so it's ONE item line). Assert it runs
681        // and contains both names.
682        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        // No arithmetic in v1, so count by appending to a var and measuring it.
690        // Build "xxx" and stop when its char count hits 3.
691        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    // -------- fuel / bounds --------
702
703    #[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        // A substitution running an unbounded loop is also fuel-bounded.
715        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    // -------- the end-to-end sample the task asks for --------
723
724    #[tokio::test]
725    async fn end_to_end_write_then_ls_grep_wc() {
726        // Create a file, then ls | grep | wc it and assert stdout.
727        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    // -------- fractal composition: the `run` builtin --------
745
746    #[tokio::test]
747    async fn run_composes_a_subscript() {
748        // A parent script runs a child .bl whose stdout it captures + combines —
749        // composition, not inlining: the child's vars don't leak to the parent.
750        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        // child printed "from child"; parent's $x is unset (subshell isolation).
753        assert_eq!(r.stdout, "[from child] x=\n");
754    }
755
756    #[tokio::test]
757    async fn run_nests_script_within_script() {
758        // a.bl runs b.bl runs (echoes) — fractal nesting, each level a real script.
759        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        // The shape the vision wants: discover .bl files, run each, combine — a
770        // pipeline of scripts. `find` yields paths; `run` executes each.
771        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        // find sorts; v1 has no field-splitting so $(find) is one word, but here
777        // there's exactly one match per iteration path is fine — assert both ran.
778        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        // A syntactically broken child must not kill the parent — exit 2, continue.
791        let files = &[("/bad.bl", "if [ 1 -eq 1 ]; then echo a")]; // missing `fi`
792        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        // A script bigger than MAX_SCRIPT_SIZE is refused before parse (DoS guard);
799        // a small script under the cap still composes to a clean zero exit.
800        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        // self.bl runs self.bl … — fractal but FINITE: shared fuel stops it with a
814        // clean Fuel error, never a hang or a stack blow-up.
815        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        // A loop flooding stderr must trip the OUTPUT cap (Other), not bypass it.
823        // The generous fuel budget makes the byte cap fire before fuel exhaustion.
824        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    // -------- host extension seam: run_builtin override --------
833
834    /// A host that adds a custom `greet` command on top of the fs builtins — the
835    /// same mechanism the CLI/browser hosts use to add `lh-*` platform commands.
836    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    // -------- `&&` / `||` short-circuit chaining --------
854
855    #[tokio::test]
856    async fn and_or_short_circuit_basics() {
857        // && runs the next only on success; || only on failure.
858        assert_eq!(run_ok(&[], "true && echo yes").await.stdout, "yes\n");
859        assert_eq!(run_ok(&[], "false && echo no").await.stdout, ""); // skipped
860        assert_eq!(run_ok(&[], "false || echo fallback").await.stdout, "fallback\n");
861        assert_eq!(run_ok(&[], "true || echo skip").await.stdout, ""); // skipped
862    }
863
864    #[tokio::test]
865    async fn and_or_mixed_chain_is_not_a_break() {
866        // `cond && a || b` — the classic ternary. A FAILED `&&` must still let the
867        // trailing `||` run (skip, not break).
868        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        // write succeeds → cat runs; pipes compose inside a chain.
882        let r = run(&mut host, "write f.txt hi && cat f.txt | wc -c").await.unwrap();
883        assert_eq!(r.stdout, "2\n");
884        // $? reflects the LAST pipeline actually run.
885        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        // The chain parses to an AndOr with the right op count.
894        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        // A lone `&` (background) is a clear syntax error, not a silent word.
902        assert_eq!(lexer::lex("sleep 1 &").unwrap_err().kind, BashErrorKind::Parse);
903        // A bare pipeline (no chain op) is still a plain Pipeline.
904        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        // The custom command works, pipes compose with it, and fs builtins
914        // (ls/cat) still fall through to the default dispatch.
915        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"); // "hello bashlite" = 2 words; ls; cat
917    }
918}