Skip to main content

xtask_todo_lib/devshell/script/
exec.rs

1//! Script interpreter: run AST (assign, command, if/for/while, source).
2
3use std::cell::RefCell;
4use std::collections::HashMap;
5use std::fmt;
6use std::io::{BufRead, Read, Write};
7use std::path::Path;
8use std::rc::Rc;
9
10use super::ast::ScriptStmt;
11use super::parse::parse_script;
12use crate::devshell::command::{execute_pipeline, ExecContext, RunResult};
13use crate::devshell::host_text;
14use crate::devshell::parser;
15use crate::devshell::vfs::Vfs;
16use crate::devshell::vm::SessionHolder;
17use crate::devshell::workspace::read_logical_file_bytes_rc;
18
19const MAX_SOURCE_DEPTH: u32 = 64;
20
21/// Load script text for `source` / REPL `source` / `. path`: workspace (guest-primary or VFS), then host (design §9).
22#[must_use]
23pub fn read_script_source_text(
24    vfs: &Rc<RefCell<Vfs>>,
25    vm_session: &Rc<RefCell<SessionHolder>>,
26    path: &str,
27) -> Option<String> {
28    if let Ok(bytes) = read_logical_file_bytes_rc(vfs, vm_session, path) {
29        if let Some(t) = host_text::script_text_from_vfs_bytes(&bytes) {
30            return Some(t);
31        }
32    }
33    host_text::read_host_text(Path::new(path)).ok()
34}
35
36/// Error from script execution (parse, command failure with `set_e`, or source failure).
37#[derive(Debug)]
38pub enum RunScriptError {
39    Parse,
40    CommandFailed,
41    Source,
42}
43
44impl fmt::Display for RunScriptError {
45    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
46        match self {
47            Self::Parse => f.write_str("script parse error"),
48            Self::CommandFailed => f.write_str("script command failed"),
49            Self::Source => f.write_str("script source error"),
50        }
51    }
52}
53
54impl std::error::Error for RunScriptError {}
55
56/// Execution context for script interpretation: VFS, variables, streams, and source depth.
57struct ExecScriptContext<'a, R, W1, W2> {
58    vfs: &'a Rc<RefCell<Vfs>>,
59    vm_session: Rc<RefCell<SessionHolder>>,
60    vars: &'a mut HashMap<String, String>,
61    set_e: &'a mut bool,
62    source_depth: u32,
63    stdin: &'a mut R,
64    stdout: &'a mut W1,
65    stderr: &'a mut W2,
66}
67
68/// Execute a single `source` statement: read file, parse, run. Returns Ok(false) if exit requested.
69fn exec_source<R, W1, W2>(
70    ctx: &mut ExecScriptContext<'_, R, W1, W2>,
71    path: &str,
72) -> Result<bool, RunScriptError>
73where
74    R: BufRead + Read,
75    W1: Write,
76    W2: Write,
77{
78    if ctx.source_depth >= MAX_SOURCE_DEPTH {
79        let _ = writeln!(ctx.stderr, "source: max depth {MAX_SOURCE_DEPTH} exceeded");
80        return Err(RunScriptError::Source);
81    }
82    let content = read_script_source_text(ctx.vfs, &ctx.vm_session, path);
83    let Some(content) = content else {
84        let _ = writeln!(ctx.stderr, "source: cannot read {path}");
85        return Err(RunScriptError::Source);
86    };
87    let lines = logical_lines(&content);
88    let sub = match parse_script(&lines) {
89        Ok(s) => s,
90        Err(e) => {
91            let _ = writeln!(ctx.stderr, "source {path}: {e}");
92            return Err(RunScriptError::Source);
93        }
94    };
95    ctx.source_depth += 1;
96    let result = exec_stmts(ctx, &sub);
97    ctx.source_depth -= 1;
98    result
99}
100
101/// Result of running one command line: success (exit 0), failed (non-zero), or exit requested.
102#[derive(Debug, Clone, Copy, PartialEq, Eq)]
103pub enum CmdOutcome {
104    Success,
105    Failed,
106    Exit,
107}
108
109/// Run one expanded command line; returns Success / Failed / Exit.
110fn run_command_line<R, W1, W2>(ctx: &mut ExecScriptContext<'_, R, W1, W2>, line: &str) -> CmdOutcome
111where
112    R: BufRead + Read,
113    W1: Write,
114    W2: Write,
115{
116    let line = expand_vars(line, ctx.vars);
117    let line = line.trim();
118    if line.is_empty() {
119        return CmdOutcome::Success;
120    }
121    let pipeline = match parser::parse_line(line) {
122        Ok(p) => p,
123        Err(e) => {
124            let _ = writeln!(ctx.stderr, "parse error: {e}");
125            return CmdOutcome::Failed;
126        }
127    };
128    let first_argv0 = pipeline
129        .commands
130        .first()
131        .and_then(|c| c.argv.first())
132        .map(String::as_str);
133    if first_argv0 == Some("exit") || first_argv0 == Some("quit") {
134        return CmdOutcome::Exit;
135    }
136    let mut vfs_ref = ctx.vfs.borrow_mut();
137    let mut sess_ref = ctx.vm_session.borrow_mut();
138    let mut exec_ctx = ExecContext {
139        vfs: &mut vfs_ref,
140        stdin: ctx.stdin,
141        stdout: ctx.stdout,
142        stderr: ctx.stderr,
143        vm_session: &mut sess_ref,
144    };
145    match execute_pipeline(&mut exec_ctx, &pipeline) {
146        Ok(RunResult::Continue) => CmdOutcome::Success,
147        Ok(RunResult::Exit) => CmdOutcome::Exit,
148        Err(e) => {
149            let _ = writeln!(ctx.stderr, "error: {e}");
150            CmdOutcome::Failed
151        }
152    }
153}
154
155/// Execute a list of statements; returns Ok(false) if exit was requested, Ok(true) if done, Err on `set_e` failure or source error.
156fn exec_stmts<R, W1, W2>(
157    ctx: &mut ExecScriptContext<'_, R, W1, W2>,
158    stmts: &[ScriptStmt],
159) -> Result<bool, RunScriptError>
160where
161    R: BufRead + Read,
162    W1: Write,
163    W2: Write,
164{
165    for stmt in stmts {
166        match stmt {
167            ScriptStmt::Assign(n, v) => {
168                ctx.vars.insert(n.clone(), v.clone());
169            }
170            ScriptStmt::SetE => *ctx.set_e = true,
171            ScriptStmt::Command(line) => {
172                let out = run_command_line(ctx, line);
173                match out {
174                    CmdOutcome::Exit => return Ok(false),
175                    CmdOutcome::Failed if *ctx.set_e => return Err(RunScriptError::CommandFailed),
176                    _ => {}
177                }
178            }
179            ScriptStmt::If {
180                cond,
181                then_body,
182                else_body,
183            } => {
184                let out = run_command_line(ctx, cond);
185                let run_body = if out == CmdOutcome::Success {
186                    then_body
187                } else {
188                    else_body.as_deref().unwrap_or(&[])
189                };
190                if !run_body.is_empty() {
191                    let cont = exec_stmts(ctx, run_body)?;
192                    if !cont {
193                        return Ok(false);
194                    }
195                }
196            }
197            ScriptStmt::For { var, words, body } => {
198                for w in words {
199                    let w_expanded = expand_vars(w, ctx.vars);
200                    ctx.vars.insert(var.clone(), w_expanded);
201                    let cont = exec_stmts(ctx, body)?;
202                    if !cont {
203                        return Ok(false);
204                    }
205                }
206            }
207            ScriptStmt::While { cond, body } => loop {
208                let out = run_command_line(ctx, cond);
209                if out != CmdOutcome::Success {
210                    break;
211                }
212                let cont = exec_stmts(ctx, body)?;
213                if !cont {
214                    return Ok(false);
215                }
216            },
217            ScriptStmt::Source(path) => {
218                let cont = exec_source(ctx, path)?;
219                if !cont {
220                    return Ok(false);
221                }
222            }
223        }
224    }
225    Ok(true)
226}
227
228/// Expand `$VAR` and `${VAR}` in `s` using `vars`; undefined names expand to empty string.
229#[must_use]
230pub fn expand_vars<S: std::hash::BuildHasher>(
231    s: &str,
232    vars: &HashMap<String, String, S>,
233) -> String {
234    let mut out = String::new();
235    let mut i = 0;
236    let bytes = s.as_bytes();
237    while i < bytes.len() {
238        if bytes[i] == b'$' && i + 1 < bytes.len() {
239            if bytes[i + 1] == b'{' {
240                let start = i + 2;
241                let mut end = start;
242                while end < bytes.len()
243                    && (bytes[end].is_ascii_alphanumeric() || bytes[end] == b'_')
244                {
245                    end += 1;
246                }
247                if end < bytes.len() && bytes[end] == b'}' {
248                    let name = std::str::from_utf8(&bytes[start..end]).unwrap_or("");
249                    out.push_str(vars.get(name).map_or("", String::as_str));
250                    i = end + 1;
251                    continue;
252                }
253            } else if bytes[i + 1] == b'_' || bytes[i + 1].is_ascii_alphabetic() {
254                let start = i + 1;
255                let mut end = start;
256                while end < bytes.len()
257                    && (bytes[end].is_ascii_alphanumeric() || bytes[end] == b'_')
258                {
259                    end += 1;
260                }
261                let name = std::str::from_utf8(&bytes[start..end]).unwrap_or("");
262                out.push_str(vars.get(name).map_or("", String::as_str));
263                i = end;
264                continue;
265            }
266        }
267        out.push(char::from(bytes[i]));
268        i += 1;
269    }
270    out
271}
272
273/// Turn script source into logical lines: join lines ending with `\`, strip `#` comments, skip blank.
274#[must_use]
275pub fn logical_lines(source: &str) -> Vec<String> {
276    let raw_lines: Vec<&str> = source.lines().collect();
277    let mut merged: Vec<String> = Vec::new();
278    let mut current = String::new();
279
280    for line in raw_lines {
281        let line = line.trim_end();
282        if current.ends_with('\\') {
283            current.pop();
284            current.push_str(line.trim_start());
285        } else {
286            if !current.is_empty() {
287                merged.push(std::mem::take(&mut current));
288            }
289            current = line.to_string();
290        }
291    }
292    if !current.is_empty() {
293        merged.push(current);
294    }
295
296    let mut out: Vec<String> = Vec::new();
297    for line in merged {
298        let comment_start = line.find('#').unwrap_or(line.len());
299        let line = line[..comment_start].trim();
300        if !line.is_empty() {
301            out.push(line.to_string());
302        }
303    }
304    out
305}
306
307/// Run script source: logical lines → parse to AST → interpret.
308///
309/// # Errors
310/// Returns `Err(RunScriptError)` on parse error (message to stderr), when `set_e` is true and a command fails, or on source failure.
311pub fn run_script<R, W1, W2>(
312    vfs: &Rc<RefCell<Vfs>>,
313    vm_session: &Rc<RefCell<SessionHolder>>,
314    script_src: &str,
315    set_e: bool,
316    stdin: &mut R,
317    stdout: &mut W1,
318    stderr: &mut W2,
319) -> Result<(), RunScriptError>
320where
321    R: BufRead + Read,
322    W1: Write,
323    W2: Write,
324{
325    let lines = logical_lines(script_src);
326    let stmts = match parse_script(&lines) {
327        Ok(s) => s,
328        Err(e) => {
329            let _ = writeln!(stderr, "script parse error: {e}");
330            return Err(RunScriptError::Parse);
331        }
332    };
333    let mut vars = HashMap::new();
334    let mut set_e_flag = set_e;
335    let mut ctx = ExecScriptContext {
336        vfs,
337        vm_session: Rc::clone(vm_session),
338        vars: &mut vars,
339        set_e: &mut set_e_flag,
340        source_depth: 0,
341        stdin,
342        stdout,
343        stderr,
344    };
345    let result = exec_stmts(&mut ctx, &stmts);
346    let cwd = vfs.borrow().cwd().to_string();
347    {
348        let mut vfs_mut = vfs.borrow_mut();
349        if let Err(e) = vm_session.borrow_mut().shutdown(&mut vfs_mut, &cwd) {
350            let _ = writeln!(stderr, "dev_shell: session shutdown: {e}");
351        }
352    }
353    result?;
354    Ok(())
355}