//! A command to execute (port of `config/command.zig`). use crate::cli_args::{ArgError, ConfigField}; use crate::formatter::EntryFormatter; use std::fmt; /// A command to execute (argv0 and args). /// /// A command is specified as a simple string such as "nvim a b c". /// By default, we expect the downstream to do some sort of shell expansion /// on this string. /// /// If a command is already expanded and the user does NOT want to do /// shell expansion (because this usually requires a round trip into /// /bin/sh or equivalent), specify a `direct:`-prefix. e.g. /// `direct:nvim a b c`. /// /// The whitespace before or around the prefix is ignored. For example, /// ` direct:nvim a b c` and `direct: nvim a b c` are equivalent. /// /// If the command is not absolute, it'll be looked up via the PATH. /// For the shell-expansion case, we let the shell do this. For the /// direct case, we do this directly. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Command { /// Execute a command directly, e.g. via `exec`. The format here /// is already structured to be ready to passed directly to `exec` /// with index zero being the command to execute. /// /// Index zero is not guaranteed to be an absolute path, and may require /// PATH lookup. It is up to the downstream to do this, usually via /// delegation to something like `execvp`. Direct(Vec), /// Execute a command via shell expansion. This provides the command /// as a single string that is expected to be expanded in some way /// (up to the downstream). Usually `/bin/sh -c`. Shell(String), } impl Default for Command { fn default() -> Self { Command::Shell(String::new()) } } /// Iterates over each argument in the command. #[derive(Debug, Clone)] pub struct ArgIterator { args: std::vec::IntoIter, } impl Iterator for ArgIterator { type Item = String; fn next(&mut self) -> Option { self.args.next() } } /// Best-effort shell string splitter matching Zig's /// `std.process.ArgIteratorGeneral(.{})` (whitespace separated, double /// quotes group, backslashes only special before a double quote). fn split_general(s: &str) -> Vec { let b = s.as_bytes(); let mut out = vec![]; let mut i = 0; loop { // skip whitespace while i < b.len() && (b[i] == b' ' || b[i] == b'\t') { i += 1; } if i >= b.len() { break; } let mut cur: Vec = vec![]; let mut quoted = false; while i < b.len() { match b[i] { b' ' | b'\t' if !quoted => break, b'\\' => { let mut n = 0; while i < b.len() && b[i] == b'\\' { n += 1; i += 1; } if i < b.len() && b[i] == b'"' { cur.extend(std::iter::repeat_n(b'\\', n / 2)); if n % 2 == 1 { cur.push(b'"'); i += 1; } } else { cur.extend(std::iter::repeat_n(b'\\', n)); } } b'"' => { quoted = !quoted; i += 1; } c => { cur.push(c); i += 1; } } } out.push(String::from_utf8_lossy(&cur).into_owned()); } out } impl Command { /// Creates a command as a single string, joining arguments as /// necessary with spaces. Its not guaranteed that this is a valid /// command; it is only meant to be human readable. #[must_use] pub fn string(&self) -> String { match self { Command::Shell(v) => v.clone(), Command::Direct(v) => v.join(" "), } } /// Get an iterator over the arguments array. /// /// For direct commands this just iterates over the array. For shell /// commands this uses a best effort shell string parser (like Zig's /// `ArgIteratorGeneral`). This is not guaranteed to be 100% accurate, /// but it works for common cases. #[must_use] pub fn arg_iterator(&self) -> ArgIterator { let args = match self { Command::Direct(v) => v.clone(), Command::Shell(v) => split_general(v), }; ArgIterator { args: args.into_iter(), } } } impl ConfigField for Command { fn parse_cli(&mut self, input: Option<&str>) -> Result<(), ArgError> { // Input is required. Whitespace on the edges isn't needed. // Commands must be non-empty. let input = input.ok_or(ArgError::ValueRequired)?; let trimmed = input.trim_matches(' '); if trimmed.is_empty() { return Err(ArgError::ValueRequired); } // If we have a `:` then we MIGHT have a prefix to specify what // tag we should use. let (direct, str) = match trimmed.find(':') { Some(idx) if &trimmed[..idx] == "direct" => (true, &trimmed[idx + 1..]), Some(idx) if &trimmed[..idx] == "shell" => (false, &trimmed[idx + 1..]), _ => (false, trimmed), }; let str = str.trim_matches(' '); *self = if direct { // We're not shell expanding, so the arguments are naively // split on spaces. Command::Direct(str.split(' ').map(str::to_string).collect()) } else { Command::Shell(str.to_string()) }; Ok(()) } fn format_entry(&self, f: &mut EntryFormatter<'_>) -> fmt::Result { match self { Command::Shell(v) => writeln!(f.writer, "{} = {v}", f.name), Command::Direct(v) => writeln!(f.writer, "{} = direct:{}", f.name, v.join(" ")), } } } #[cfg(test)] #[allow(clippy::unwrap_used)] mod tests { use super::*; use crate::formatter::format_entry; #[test] fn command_parse_cli_errors() { let mut v = Command::default(); assert_eq!(v.parse_cli(None), Err(ArgError::ValueRequired)); assert_eq!(v.parse_cli(Some("")), Err(ArgError::ValueRequired)); assert_eq!(v.parse_cli(Some(" ")), Err(ArgError::ValueRequired)); } #[test] fn command_parse_cli_shell_expanded() { let mut v = Command::default(); v.parse_cli(Some("echo hello")).unwrap(); assert_eq!(v, Command::Shell("echo hello".into())); // Spaces are stripped v.parse_cli(Some(" echo hello ")).unwrap(); assert_eq!(v, Command::Shell("echo hello".into())); } #[test] fn command_parse_cli_direct() { let mut v = Command::default(); v.parse_cli(Some("direct:echo hello")).unwrap(); assert_eq!(v, Command::Direct(vec!["echo".into(), "hello".into()])); // Spaces around the prefix v.parse_cli(Some(" direct: echo hello")).unwrap(); assert_eq!(v, Command::Direct(vec!["echo".into(), "hello".into()])); } #[test] fn command_arg_iterator_shell() { let v = Command::Shell("echo hello world".into()); let mut it = v.arg_iterator(); assert_eq!(it.next().unwrap(), "echo"); assert_eq!(it.next().unwrap(), "hello"); assert_eq!(it.next().unwrap(), "world"); assert!(it.next().is_none()); } #[test] fn command_arg_iterator_shell_quotes() { let v = Command::Shell("echo \"hello world\" a\\\"b".into()); let args: Vec = v.arg_iterator().collect(); assert_eq!(args, ["echo", "hello world", "a\"b"]); } #[test] fn command_arg_iterator_direct() { let v = Command::Direct(vec!["echo".into(), "hello world".into()]); let mut it = v.arg_iterator(); assert_eq!(it.next().unwrap(), "echo"); assert_eq!(it.next().unwrap(), "hello world"); assert!(it.next().is_none()); } #[test] fn command_string_shell() { let v = Command::Shell("echo hello world".into()); assert_eq!(v.string(), "echo hello world"); } #[test] fn command_string_direct() { let v = Command::Direct(vec!["echo".into(), "hello world".into()]); assert_eq!(v.string(), "echo hello world"); } #[test] fn command_format_config_shell() { let mut v = Command::default(); v.parse_cli(Some("echo hello")).unwrap(); let mut s = String::new(); format_entry("a", &v, &mut s).unwrap(); assert_eq!(s, "a = echo hello\n"); } #[test] fn command_format_config_direct() { let mut v = Command::default(); v.parse_cli(Some("direct: echo hello")).unwrap(); let mut s = String::new(); format_entry("a", &v, &mut s).unwrap(); assert_eq!(s, "a = direct:echo hello\n"); } }