Skip to main content

command_stream/shelljs/
mod.rs

1//! ShellJS-style asynchronous command calls backed by the portable command
2//! engine. Sessions own their cwd, directory stack, configuration and errors.
3//! See `js/docs/SHELLJS_MIGRATION.md` for the JavaScript/Rust mapping and limits.
4mod extra;
5
6use crate::commands::{cd::resolve_cd, VirtualCommandRegistry};
7use crate::{
8    quote, CommandContext, CommandResult, ProcessRunner, Result, RunOptions, StreamingRunner,
9};
10use std::collections::HashMap;
11use std::io::Write;
12use std::path::PathBuf;
13
14/// Configuration for a ShellJS-style session.
15#[derive(Debug, Clone)]
16pub struct Config {
17    /// Suppress mirroring of completed results.
18    pub silent: bool,
19    /// Convert nonzero exit codes to `Error::CommandFailed`.
20    pub fatal: bool,
21    /// Print commands before execution.
22    pub verbose: bool,
23    /// Expand file operand globs relative to the session cwd.
24    pub glob: bool,
25}
26impl Default for Config {
27    fn default() -> Self {
28        Self {
29            silent: false,
30            fatal: false,
31            verbose: false,
32            glob: true,
33        }
34    }
35}
36
37/// A portable Rust counterpart of the ShellJS command-call interface.
38/// Arguments are individual values; command calls never concatenate them as
39/// shell syntax. Unlike JavaScript ShellJS, cwd changes affect this session only.
40#[derive(Debug)]
41pub struct ShellJs {
42    /// Session configuration.
43    pub config: Config,
44    /// Environment used by commands.
45    pub env: HashMap<String, String>,
46    cwd: PathBuf,
47    stack: Vec<PathBuf>,
48    last_error: Option<String>,
49    last_code: i32,
50}
51impl Default for ShellJs {
52    fn default() -> Self {
53        Self::new()
54    }
55}
56
57macro_rules! methods {
58    ($($name:ident),* $(,)?) => { $(
59        #[doc = concat!("Run `", stringify!($name), "` with separate argument values.")]
60        pub async fn $name(&mut self, args: &[&str]) -> Result<CommandResult> {
61            self.call(stringify!($name), args).await
62        }
63    )* };
64}
65
66impl ShellJs {
67    /// Create a session rooted in the current directory.
68    pub fn new() -> Self {
69        let cwd = std::env::current_dir().unwrap_or_else(|_| std::env::temp_dir());
70        let mut env: HashMap<String, String> = std::env::vars().collect();
71        env.insert("PWD".into(), cwd.display().to_string());
72        Self {
73            config: Config::default(),
74            env,
75            cwd,
76            stack: Vec::new(),
77            last_error: None,
78            last_code: 0,
79        }
80    }
81    /// Current session directory.
82    pub fn cwd(&self) -> &std::path::Path {
83        &self.cwd
84    }
85    /// Error text from the last command, or None after success.
86    pub fn error(&self) -> Option<&str> {
87        self.last_error.as_deref()
88    }
89    /// Exit code of the last command.
90    pub fn error_code(&self) -> i32 {
91        self.last_code
92    }
93
94    fn finish(&mut self, result: CommandResult) -> Result<CommandResult> {
95        self.last_code = result.code;
96        self.last_error = if result.code != 0 {
97            Some(result.stderr.to_string())
98        } else {
99            None
100        };
101        if !self.config.silent {
102            let _ = std::io::stdout().write_all(result.stdout.as_bytes());
103            let _ = std::io::stderr().write_all(result.stderr.as_bytes());
104        }
105        if self.config.fatal {
106            result.error_for_status()
107        } else {
108            Ok(result)
109        }
110    }
111
112    fn context(&self, args: &[&str]) -> CommandContext {
113        let mut context = CommandContext::new(args.iter().map(|arg| arg.to_string()).collect());
114        context.cwd = Some(self.cwd.clone());
115        context.env = Some(self.env.clone());
116        context
117    }
118
119    fn expand(&self, command: &str, args: &[&str]) -> Vec<String> {
120        args.iter()
121            .flat_map(|arg| {
122                if !self.config.glob
123                    || matches!(
124                        command,
125                        "echo" | "sed" | "grep" | "test" | "basename" | "dirname"
126                    )
127                    || arg.starts_with('-')
128                    || !arg.contains(['*', '?', '['])
129                {
130                    return vec![arg.to_string()];
131                }
132                let pattern = self.cwd.join(arg).display().to_string();
133                let matches = glob::glob(&pattern)
134                    .map(|paths| {
135                        paths
136                            .flatten()
137                            .map(|path| path.display().to_string())
138                            .collect::<Vec<_>>()
139                    })
140                    .unwrap_or_default();
141                if matches.is_empty() {
142                    vec![arg.to_string()]
143                } else {
144                    matches
145                }
146            })
147            .collect()
148    }
149
150    /// Run a supported ShellJS command. Unsupported command names return 127;
151    /// external programs use `cmd` (argv) or `exec` (explicit shell syntax).
152    pub async fn call(&mut self, command: &str, args: &[&str]) -> Result<CommandResult> {
153        if command == "cd" {
154            return self.cd(args).await;
155        }
156        let expanded = self.expand(command, args);
157        let args: Vec<&str> = expanded.iter().map(String::as_str).collect();
158        if self.config.verbose {
159            eprintln!("{command} {args:?}");
160        }
161        let context = self.context(&args);
162        let registry = VirtualCommandRegistry::with_builtins();
163        let result = match command {
164            "find" | "grep" | "sed" | "ln" | "chmod" => extra::run(command, context).await,
165            _ => match registry.get(command) {
166                Some(handler) => handler(context).await,
167                None => CommandResult::error_with_code(
168                    format!("shelljs: unsupported command: {command}"),
169                    127,
170                ),
171            },
172        };
173        self.finish(result)
174    }
175
176    methods!(
177        cat, chmod, cp, echo, find, grep, head, ln, ls, mkdir, mv, pwd, rm, sed, sort, tail, touch,
178        uniq, which, basename, dirname, sleep, env, seq, tee
179    );
180
181    /// Change this session's directory without changing the host process cwd.
182    pub async fn cd(&mut self, args: &[&str]) -> Result<CommandResult> {
183        let (result, context) = resolve_cd(self.context(args)).await;
184        if let Some(context) = context {
185            self.env
186                .insert("PWD".into(), context.cwd.display().to_string());
187            self.env
188                .insert("OLDPWD".into(), context.oldpwd.display().to_string());
189            self.cwd = context.cwd;
190        }
191        self.finish(result)
192    }
193    /// Evaluate a file test, returning a boolean.
194    pub async fn test(&mut self, args: &[&str]) -> Result<bool> {
195        Ok(self.call("test", args).await?.code == 0)
196    }
197    /// Execute a program with exact arguments, bypassing the shell.
198    pub async fn cmd(&mut self, program: &str, args: &[&str]) -> Result<CommandResult> {
199        let result = StreamingRunner::from_argv(program, args)
200            .cwd(self.cwd.clone())
201            .env(self.env.clone())
202            .collect()
203            .await?;
204        self.finish(result)
205    }
206    /// Execute an explicitly supplied shell command.
207    pub async fn exec(&mut self, command: &str) -> Result<CommandResult> {
208        let options = RunOptions {
209            mirror: false,
210            cwd: Some(self.cwd.clone()),
211            env: Some(self.env.clone()),
212            ..RunOptions::default()
213        };
214        let result = ProcessRunner::new(command, options).run().await?;
215        self.finish(result)
216    }
217    /// List the current directory followed by the saved directory stack.
218    pub fn dirs(&self) -> CommandResult {
219        CommandResult::success(
220            std::iter::once(&self.cwd)
221                .chain(self.stack.iter().rev())
222                .map(|path| path.display().to_string())
223                .collect::<Vec<_>>()
224                .join("\n"),
225        )
226    }
227    /// Save the current directory and change to the provided directory.
228    pub async fn pushd(&mut self, args: &[&str]) -> Result<CommandResult> {
229        let previous = self.cwd.clone();
230        let result = self.cd(args).await?;
231        if result.code == 0 {
232            self.stack.push(previous);
233        }
234        Ok(result)
235    }
236    /// Restore the most recently saved directory.
237    pub async fn popd(&mut self) -> Result<CommandResult> {
238        let Some(path) = self.stack.last().cloned() else {
239            return self.finish(CommandResult::error("popd: directory stack empty"));
240        };
241        let result = self.cd(&[&path.display().to_string()]).await?;
242        if result.code == 0 {
243            self.stack.pop();
244        }
245        Ok(result)
246    }
247    /// Return the platform temporary directory.
248    pub fn tempdir(&self) -> PathBuf {
249        std::env::temp_dir()
250    }
251    /// Set fatal (`-e`/`+e`), verbose (`-v`/`+v`) or glob (`-f`/`+f`) modes.
252    pub fn set(&mut self, option: &str) -> Result<CommandResult> {
253        match option {
254            "-e" => self.config.fatal = true,
255            "+e" => self.config.fatal = false,
256            "-v" => self.config.verbose = true,
257            "+v" => self.config.verbose = false,
258            "-f" => self.config.glob = false,
259            "+f" => self.config.glob = true,
260            _ => return self.finish(CommandResult::error("set: unsupported option")),
261        }
262        self.finish(CommandResult::success_empty())
263    }
264    /// Write captured stdout to a file (`append=true` corresponds to `toEnd`).
265    pub async fn to(
266        &mut self,
267        result: &CommandResult,
268        path: &str,
269        append: bool,
270    ) -> Result<CommandResult> {
271        use tokio::io::AsyncWriteExt;
272        let write = async {
273            let mut file = tokio::fs::OpenOptions::new()
274                .write(true)
275                .create(true)
276                .append(append)
277                .truncate(!append)
278                .open(self.cwd.join(path))
279                .await?;
280            file.write_all(result.stdout.as_bytes()).await?;
281            file.flush().await
282        }
283        .await;
284        self.finish(match write {
285            Ok(()) => CommandResult::success_empty(),
286            Err(error) => CommandResult::error(format!("to: {path}: {error}")),
287        })
288    }
289    /// Build a safely quoted native command for explicit mixed pipelines.
290    pub fn command(&self, name: &str, args: &[&str]) -> String {
291        std::iter::once(name)
292            .chain(args.iter().copied())
293            .map(quote)
294            .collect::<Vec<_>>()
295            .join(" ")
296    }
297}