Skip to main content

localharness/bashlite/
builtins.rs

1//! The v1 builtin commands — fs-only, over [`crate::filesystem::Filesystem`].
2//!
3//! Read/search/create only: `echo`, `ls`, `cat`, `wc`, `grep`, `find`, `mkdir`,
4//! and `write`/`create` (create-only — they refuse to overwrite). NO
5//! value-moving / `lh-*` platform commands (v2). `cd` is NOT here — it mutates
6//! the evaluator's current directory, so the evaluator handles it directly.
7//!
8//! Every builtin is TOTAL: a bad path / missing file / regex error becomes a
9//! nonzero exit + stderr text, never a panic. Paths are resolved against `cwd`
10//! ([`resolve`]); the sandbox root is `/` and there is no escaping it
11//! (`..` that would climb above root clamps to root).
12
13use crate::filesystem::{EntryKind, Filesystem};
14
15use super::host::Output;
16
17/// Dispatch a builtin by name. Unknown commands return a nonzero exit (so a
18/// script can branch on it) rather than erroring the whole run — matching a
19/// shell's `command not found` (exit 127).
20pub async fn dispatch(fs: &dyn Filesystem, cmd: &str, args: &[String], stdin: &str) -> Output {
21    dispatch_in(fs, "/", cmd, args, stdin).await
22}
23
24/// Dispatch with an explicit current directory (the evaluator's `cwd`).
25pub async fn dispatch_in(
26    fs: &dyn Filesystem,
27    cwd: &str,
28    cmd: &str,
29    args: &[String],
30    stdin: &str,
31) -> Output {
32    match cmd {
33        "echo" => echo(args),
34        "true" => Output::ok(""),
35        "false" => Output { stdout: String::new(), stderr: String::new(), code: 1 },
36        "[" | "test" => test_cmd(fs, cwd, cmd, args).await,
37        "ls" => ls(fs, cwd, args).await,
38        "cat" => cat(fs, cwd, args).await,
39        "wc" => Output::ok(wc(args, stdin)),
40        "grep" => grep(args, stdin),
41        "head" => head_tail(args, stdin, true),
42        "tail" => head_tail(args, stdin, false),
43        "find" => find(fs, cwd, args).await,
44        "mkdir" => mkdir(fs, cwd, args).await,
45        "write" | "create" => write_create(fs, cwd, args).await,
46        other => Output::err(format!("{other}: command not found"), 127),
47    }
48}
49
50/// `echo [-n] ARGS...` — print args space-joined + newline (`-n` omits it).
51fn echo(args: &[String]) -> Output {
52    let (no_newline, rest) = match args.first() {
53        Some(f) if f == "-n" => (true, &args[1..]),
54        _ => (false, args),
55    };
56    let mut s = rest.join(" ");
57    if !no_newline {
58        s.push('\n');
59    }
60    Output::ok(s)
61}
62
63/// `ls [path...]` — list directory contents (names only, one per line, sorted).
64/// With no args, lists `cwd`. A file path lists just that name.
65async fn ls(fs: &dyn Filesystem, cwd: &str, args: &[String]) -> Output {
66    let targets: Vec<String> =
67        if args.is_empty() { vec![cwd.to_string()] } else { args.iter().map(|a| resolve(cwd, a)).collect() };
68    let multi = targets.len() > 1;
69    let mut out = String::new();
70    let mut code = 0;
71    for (i, t) in targets.iter().enumerate() {
72        match fs.metadata(t).await {
73            Ok(Some(m)) if m.kind == EntryKind::Directory => {
74                match fs.read_dir(t).await {
75                    Ok(mut entries) => {
76                        entries.sort_by(|a, b| a.name.cmp(&b.name));
77                        if multi {
78                            out.push_str(&format!("{}:\n", display_path(t)));
79                        }
80                        for e in entries {
81                            out.push_str(&e.name);
82                            out.push('\n');
83                        }
84                        if multi && i + 1 < targets.len() {
85                            out.push('\n');
86                        }
87                    }
88                    Err(e) => {
89                        return Output::err(format!("ls: {}: {e}", display_path(t)), 1);
90                    }
91                }
92            }
93            Ok(Some(_)) => {
94                // A plain file — echo the path (like `ls file`).
95                out.push_str(&display_path(t));
96                out.push('\n');
97            }
98            Ok(None) => {
99                return Output::err(format!("ls: {}: no such file or directory", display_path(t)), 1);
100            }
101            Err(e) => {
102                code = 1;
103                out.push_str(&format!("ls: {}: {e}\n", display_path(t)));
104            }
105        }
106    }
107    Output { stdout: out, stderr: String::new(), code }
108}
109
110/// `cat PATH...` — concatenate file contents to stdout.
111async fn cat(fs: &dyn Filesystem, cwd: &str, args: &[String]) -> Output {
112    if args.is_empty() {
113        return Output::err("cat: no file given", 1);
114    }
115    let mut out = String::new();
116    for a in args {
117        let p = resolve(cwd, a);
118        match fs.read(&p).await {
119            Ok(bytes) => out.push_str(&String::from_utf8_lossy(&bytes)),
120            Err(e) => return Output::err(format!("cat: {a}: {e}"), 1),
121        }
122    }
123    Output::ok(out)
124}
125
126/// `wc [-l|-w|-c]` — count lines/words/bytes of stdin (default: `lines words
127/// bytes`). Operates on piped stdin only in v1 (no file args).
128fn wc(args: &[String], stdin: &str) -> String {
129    let lines = stdin.lines().count();
130    let words = stdin.split_whitespace().count();
131    let bytes = stdin.len();
132    match args.first().map(String::as_str) {
133        Some("-l") => format!("{lines}\n"),
134        Some("-w") => format!("{words}\n"),
135        Some("-c") => format!("{bytes}\n"),
136        _ => format!("{lines} {words} {bytes}\n"),
137    }
138}
139
140/// `grep PATTERN` — filter stdin to lines containing PATTERN (literal substring;
141/// `-i` case-insensitive, `-v` invert, `-c` count). v1 is a literal matcher (no
142/// regex) — deterministic + total, and enough for the common pipe filter. (v2:
143/// real regex via the existing search_directory engine.)
144fn grep(args: &[String], stdin: &str) -> Output {
145    let mut invert = false;
146    let mut ignore_case = false;
147    let mut count = false;
148    let mut pattern: Option<&str> = None;
149    for a in args {
150        match a.as_str() {
151            "-v" => invert = true,
152            "-i" => ignore_case = true,
153            "-c" => count = true,
154            // Combined short flags like `-iv`.
155            s if s.starts_with('-') && s.len() > 1 && s[1..].chars().all(|c| "vic".contains(c)) => {
156                for c in s[1..].chars() {
157                    match c {
158                        'v' => invert = true,
159                        'i' => ignore_case = true,
160                        'c' => count = true,
161                        _ => {}
162                    }
163                }
164            }
165            _ => {
166                if pattern.is_none() {
167                    pattern = Some(a);
168                }
169            }
170        }
171    }
172    let Some(pat) = pattern else {
173        return Output::err("grep: no pattern given", 2);
174    };
175    let needle = if ignore_case { pat.to_lowercase() } else { pat.to_string() };
176    let mut matched = Vec::new();
177    for line in stdin.lines() {
178        let hay = if ignore_case { line.to_lowercase() } else { line.to_string() };
179        let hit = hay.contains(&needle);
180        if hit != invert {
181            matched.push(line);
182        }
183    }
184    // grep's exit is 1 when nothing matched (lets scripts branch on it) —
185    // independent of -c, which only changes the OUTPUT (a count), NOT the exit
186    // status. POSIX: exit 0 iff one or more lines were selected.
187    let code = if matched.is_empty() { 1 } else { 0 };
188    if count {
189        return Output { stdout: format!("{}\n", matched.len()), stderr: String::new(), code };
190    }
191    let mut out = matched.join("\n");
192    if !out.is_empty() {
193        out.push('\n');
194    }
195    Output { stdout: out, stderr: String::new(), code }
196}
197
198/// `find [path] [-name GLOB] [-type f|d]` — recursively list paths under `path`
199/// (default `cwd`), optionally filtered by a name glob and/or entry type.
200async fn find(fs: &dyn Filesystem, cwd: &str, args: &[String]) -> Output {
201    let mut root: Option<String> = None;
202    let mut name_glob: Option<String> = None;
203    let mut type_filter: Option<EntryKind> = None;
204    let mut i = 0;
205    while i < args.len() {
206        match args[i].as_str() {
207            "-name" => {
208                i += 1;
209                match args.get(i) {
210                    Some(g) => name_glob = Some(g.clone()),
211                    None => return Output::err("find: -name needs an argument", 1),
212                }
213            }
214            "-type" => {
215                i += 1;
216                match args.get(i).map(String::as_str) {
217                    Some("f") => type_filter = Some(EntryKind::File),
218                    Some("d") => type_filter = Some(EntryKind::Directory),
219                    _ => return Output::err("find: -type expects f or d", 1),
220                }
221            }
222            other if root.is_none() && !other.starts_with('-') => root = Some(resolve(cwd, other)),
223            other => return Output::err(format!("find: unknown argument: {other}"), 1),
224        }
225        i += 1;
226    }
227    let root = root.unwrap_or_else(|| cwd.to_string());
228    match fs.walk(&root, None).await {
229        Ok(mut entries) => {
230            entries.sort_by(|a, b| a.path.cmp(&b.path));
231            let mut out = String::new();
232            for e in entries {
233                if let Some(t) = type_filter {
234                    if e.kind != t {
235                        continue;
236                    }
237                }
238                if let Some(g) = &name_glob {
239                    let name = crate::filesystem::file_name(&e.path);
240                    if !glob_match(g, name) {
241                        continue;
242                    }
243                }
244                out.push_str(&display_path(&e.path));
245                out.push('\n');
246            }
247            Output::ok(out)
248        }
249        Err(e) => Output::err(format!("find: {}: {e}", display_path(&root)), 1),
250    }
251}
252
253/// `mkdir PATH...` — create a directory (and parents). Implemented by writing a
254/// `.keep` marker under it (OPFS/Native both auto-create parent dirs on a write,
255/// and OPFS has no empty-dir concept). Idempotent.
256async fn mkdir(fs: &dyn Filesystem, cwd: &str, args: &[String]) -> Output {
257    if args.is_empty() {
258        return Output::err("mkdir: no directory given", 1);
259    }
260    for a in args {
261        // `-p` is the default behaviour (parents always created); accept + skip.
262        if a == "-p" {
263            continue;
264        }
265        let dir = resolve(cwd, a);
266        let marker = format!("{}/.keep", dir.trim_end_matches('/'));
267        if let Err(e) = fs.write_atomic(&marker, b"").await {
268            return Output::err(format!("mkdir: {a}: {e}"), 1);
269        }
270    }
271    Output::ok("")
272}
273
274/// `head [-n N | -N]` / `tail [-n N | -N]` — the first / last N lines of stdin
275/// (default 10). A pure stdin processor like `grep`/`wc`, so `lh-discover coding
276/// | head -3` takes the top matches. Bad args → exit 2, never a panic.
277fn head_tail(args: &[String], stdin: &str, from_head: bool) -> Output {
278    let n = match parse_line_count(args) {
279        Ok(n) => n,
280        Err(msg) => return Output::err(msg, 2),
281    };
282    let lines: Vec<&str> = stdin.lines().collect();
283    let selected: &[&str] = if from_head {
284        &lines[..lines.len().min(n)]
285    } else {
286        &lines[lines.len().saturating_sub(n)..]
287    };
288    let mut out = selected.join("\n");
289    if !out.is_empty() {
290        out.push('\n');
291    }
292    Output::ok(out)
293}
294
295/// Parse a `head`/`tail` line count: no args → 10; `-n N` (two args); `-nN` or
296/// `-N` (one arg). Anything else is a usage error.
297fn parse_line_count(args: &[String]) -> Result<usize, String> {
298    let count = |s: &str| s.parse::<usize>().map_err(|_| format!("head/tail: invalid line count: {s}"));
299    match args {
300        [] => Ok(10),
301        [a, b] if a == "-n" => count(b),
302        [a] if a == "-n" => Err("head/tail: option requires an argument: -n".to_string()),
303        [a] if a.starts_with("-n") => count(&a[2..]),
304        [a] if a.starts_with('-') && a.len() > 1 => count(&a[1..]),
305        _ => Err("head/tail: usage: head|tail [-n N] (reads stdin)".to_string()),
306    }
307}
308
309/// `write PATH CONTENT...` / `create PATH CONTENT...` — create a NEW file with
310/// the space-joined content (CREATE ONLY: refuses to overwrite an existing
311/// file, so a script can't clobber data in v1). With no content, writes piped
312/// stdin instead.
313async fn write_create(fs: &dyn Filesystem, cwd: &str, args: &[String]) -> Output {
314    let Some(path) = args.first() else {
315        return Output::err("write: usage: write PATH [CONTENT...]", 1);
316    };
317    let p = resolve(cwd, path);
318    match fs.metadata(&p).await {
319        Ok(Some(_)) => {
320            return Output::err(format!("write: {path}: already exists (create-only in v1)"), 1)
321        }
322        Ok(None) => {}
323        Err(e) => return Output::err(format!("write: {path}: {e}"), 1),
324    }
325    let content = args[1..].join(" ");
326    match fs.write_atomic(&p, content.as_bytes()).await {
327        Ok(()) => Output::ok(""),
328        Err(e) => Output::err(format!("write: {path}: {e}"), 1),
329    }
330}
331
332/// `[ EXPR ]` / `test EXPR` — the POSIX test builtin (subset). Supported:
333/// string `=`/`!=`, `-z`/`-n`, integer `-eq -ne -lt -le -gt -ge`, FILE tests
334/// `-e`/`-f`/`-d PATH` (exists / is a regular file / is a directory, over the
335/// sandbox fs), and a single non-empty operand. Exit 0 = true, 1 = false, 2 =
336/// error. The file tests make the create-only `write` safe to guard
337/// (`[ -f x ] || write x …`).
338async fn test_cmd(fs: &dyn Filesystem, cwd: &str, cmd: &str, args: &[String]) -> Output {
339    // `[` requires a trailing `]`; strip it. `test` must not have one.
340    let operands: Vec<&str> = if cmd == "[" {
341        match args.last() {
342            Some(last) if last == "]" => args[..args.len() - 1].iter().map(String::as_str).collect(),
343            _ => return Output::err("[: missing closing `]`", 2),
344        }
345    } else {
346        args.iter().map(String::as_str).collect()
347    };
348    // File tests need the fs (existence + kind); everything else is pure.
349    if operands.len() == 2 && matches!(operands[0], "-e" | "-f" | "-d") {
350        let resolved = resolve(cwd, operands[1]);
351        let truthy = match fs.metadata(&resolved).await {
352            Ok(Some(m)) => match operands[0] {
353                "-e" => true,
354                "-f" => m.kind == EntryKind::File,
355                "-d" => m.kind == EntryKind::Directory,
356                _ => false,
357            },
358            // Missing or unreadable → false (the file isn't there / usable).
359            _ => false,
360        };
361        return Output { stdout: String::new(), stderr: String::new(), code: i32::from(!truthy) };
362    }
363    match eval_test(&operands) {
364        Ok(true) => Output { stdout: String::new(), stderr: String::new(), code: 0 },
365        Ok(false) => Output { stdout: String::new(), stderr: String::new(), code: 1 },
366        Err(msg) => Output::err(format!("{cmd}: {msg}"), 2),
367    }
368}
369
370fn eval_test(ops: &[&str]) -> Result<bool, String> {
371    match ops {
372        // Empty test is false.
373        [] => Ok(false),
374        // A single operand: true iff non-empty.
375        [a] => Ok(!a.is_empty()),
376        // Unary string ops.
377        ["-z", a] => Ok(a.is_empty()),
378        ["-n", a] => Ok(!a.is_empty()),
379        // Binary ops.
380        [a, op, b] => match *op {
381            "=" | "==" => Ok(a == b),
382            "!=" => Ok(a != b),
383            "-eq" | "-ne" | "-lt" | "-le" | "-gt" | "-ge" => {
384                let x: i64 = a.parse().map_err(|_| format!("integer expected: {a}"))?;
385                let y: i64 = b.parse().map_err(|_| format!("integer expected: {b}"))?;
386                Ok(match *op {
387                    "-eq" => x == y,
388                    "-ne" => x != y,
389                    "-lt" => x < y,
390                    "-le" => x <= y,
391                    "-gt" => x > y,
392                    "-ge" => x >= y,
393                    _ => unreachable!(),
394                })
395            }
396            other => Err(format!("unknown operator: {other}")),
397        },
398        _ => Err("too many arguments".to_string()),
399    }
400}
401
402// ---------------------------------------------------------------------------
403// Path + glob helpers
404// ---------------------------------------------------------------------------
405
406/// Resolve `path` against `cwd` into a normalized absolute sandbox path. An
407/// absolute `path` (leading `/`) ignores `cwd`. `.`/`..`/empty components are
408/// collapsed; a `..` at root clamps to root (no sandbox escape). Always returns
409/// a leading-`/` path with no trailing slash (except the root `/`).
410pub(crate) fn resolve(cwd: &str, path: &str) -> String {
411    let base = if path.starts_with('/') { String::new() } else { cwd.to_string() };
412    let combined = format!("{base}/{path}");
413    let mut stack: Vec<&str> = Vec::new();
414    for comp in combined.split('/') {
415        match comp {
416            "" | "." => {}
417            ".." => {
418                stack.pop();
419            }
420            c => stack.push(c),
421        }
422    }
423    if stack.is_empty() {
424        "/".to_string()
425    } else {
426        format!("/{}", stack.join("/"))
427    }
428}
429
430/// Render a resolved path for display: strip the leading `/` so output reads
431/// like a relative listing (`a/b.rl` not `/a/b.rl`), keeping `/` for root.
432fn display_path(p: &str) -> String {
433    match p.strip_prefix('/') {
434        Some("") | None => p.to_string(),
435        Some(rest) => rest.to_string(),
436    }
437}
438
439/// Minimal glob match: `*` (any run), `?` (one char), literals otherwise.
440/// Anchored at both ends (whole-name match), like `find -name`.
441pub(crate) fn glob_match(pat: &str, name: &str) -> bool {
442    let p: Vec<char> = pat.chars().collect();
443    let n: Vec<char> = name.chars().collect();
444    glob_rec(&p, &n)
445}
446
447fn glob_rec(p: &[char], n: &[char]) -> bool {
448    match p.first() {
449        None => n.is_empty(),
450        Some('*') => {
451            // `*` matches zero+ chars: try consuming none, else one and recurse.
452            glob_rec(&p[1..], n) || (!n.is_empty() && glob_rec(p, &n[1..]))
453        }
454        Some('?') => !n.is_empty() && glob_rec(&p[1..], &n[1..]),
455        Some(c) => n.first() == Some(c) && glob_rec(&p[1..], &n[1..]),
456    }
457}