scallop 0.0.30

Wrapper library for bash
Documentation
use std::ffi::{CStr, CString};
use std::ptr;
use std::str::FromStr;
use std::sync::LazyLock;

use bitflags::bitflags;
use itertools::Itertools;

use crate::error::{Error, ok_or_error};
use crate::{ExecStatus, bash};

bitflags! {
    /// Flag values used with commands.
    #[derive(Debug, Default)]
    pub struct Flags: u32 {
        const NONE = 0;
        const WANT_SUBSHELL = bash::CMD_WANT_SUBSHELL;
        const FORCE_SUBSHELL = bash::CMD_FORCE_SUBSHELL;
        const INVERT_RETURN = bash::CMD_INVERT_RETURN;
        const IGNORE_RETURN = bash::CMD_IGNORE_RETURN;
        const NO_FUNCTIONS = bash::CMD_NO_FUNCTIONS;
        const INHIBIT_EXPANSION = bash::CMD_INHIBIT_EXPANSION;
        const NO_FORK = bash::CMD_NO_FORK;
    }
}

/// Return the currently executing command string if it exists.
///
/// This is the raw command string without any bash manipulation.
pub fn current_command_string() -> crate::Result<String> {
    unsafe {
        bash::CURRENT_COMMAND_STRING
            .as_ref()
            .map(|p| CStr::from_ptr(p).to_string_lossy().to_string())
            .ok_or_else(|| Error::Base("no running command".to_string()))
    }
}

/// Return the currently executing command name if it exists.
pub fn current_command_name() -> crate::Result<String> {
    unsafe {
        bash::CURRENT_COMMAND_NAME
            .as_ref()
            .map(|p| CStr::from_ptr(p).to_string_lossy().to_string())
            .ok_or_else(|| Error::Base("no running command".to_string()))
    }
}

#[derive(Debug, Default)]
pub struct Command {
    pub args: Vec<String>,
    flags: Flags,
}

impl Command {
    pub fn new<S: std::fmt::Display>(program: S) -> Self {
        Self {
            args: vec![program.to_string()],
            ..Default::default()
        }
    }

    pub fn args<I>(&mut self, args: I) -> &mut Self
    where
        I: IntoIterator,
        I::Item: Into<String>,
    {
        self.args.extend(args.into_iter().map(Into::into));
        self
    }

    pub fn subshell(&mut self, value: bool) -> &mut Self {
        if value {
            self.flags = Flags::FORCE_SUBSHELL;
        }
        self
    }

    pub fn invert(&mut self, value: bool) -> &mut Self {
        if value {
            self.flags = Flags::INVERT_RETURN;
        }
        self
    }

    pub fn execute(&self) -> crate::Result<ExecStatus> {
        let cmd: BashCommand = self.try_into()?;
        cmd.execute()
    }
}

impl FromStr for Command {
    type Err = Error;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        // TODO: use shlex to split string
        Ok(Self {
            args: s.split(' ').map(Into::into).collect(),
            ..Default::default()
        })
    }
}

impl TryFrom<&Command> for BashCommand {
    type Error = Error;

    fn try_from(value: &Command) -> crate::Result<Self> {
        let cmd: Self = value.args.iter().join(" ").parse()?;

        // apply flags
        unsafe { (*cmd.0).flags |= value.flags.bits() as i32 };

        Ok(cmd)
    }
}

/// Wrapper for a raw bash command.
#[derive(Debug)]
struct BashCommand(*mut bash::Command);

impl BashCommand {
    fn execute(&self) -> crate::Result<ExecStatus> {
        ok_or_error(|| match unsafe { bash::scallop_execute_command(self.0) } {
            0 => Ok(ExecStatus::Success),
            n => Err(Error::Status(n)),
        })
    }
}

impl FromStr for BashCommand {
    type Err = Error;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        let cmd_str = CString::new(s).unwrap();
        let cmd_ptr = cmd_str.as_ptr() as *mut _;
        let name_ptr = COMMAND_MARKER.as_ptr();

        unsafe {
            // save input stream
            bash::push_stream(1);

            // parse command from string
            bash::with_input_from_string(cmd_ptr, name_ptr);
            let cmd = match bash::parse_command() {
                0 => bash::copy_command(bash::GLOBAL_COMMAND),
                _ => return Err(Error::Base(format!("failed parsing: {s}"))),
            };

            // Override line number to avoid "line 1" being used as an error prolog. Since the
            // value field is a union type it doesn't matter which one is used.
            (*(*cmd).value.Simple).line = 0;

            // clean up global command
            bash::dispose_command(bash::GLOBAL_COMMAND);
            bash::GLOBAL_COMMAND = ptr::null_mut();

            // restore input stream
            bash::pop_stream();

            Ok(Self(cmd))
        }
    }
}

impl Drop for BashCommand {
    fn drop(&mut self) {
        unsafe { bash::dispose_command(self.0) };
    }
}

static COMMAND_MARKER: LazyLock<CString> =
    LazyLock::new(|| CString::new("Command::from_str").unwrap());

#[cfg(test)]
mod tests {
    use crate::source;
    use crate::test::assert_err_re;
    use crate::variables::optional;

    use super::*;

    #[test]
    fn new_and_execute() {
        let cmd: Command = "VAR=0".parse().unwrap();
        cmd.execute().unwrap();
        assert_eq!(optional("VAR").unwrap(), "0");

        let mut cmd: Command = "VAR=1".parse().unwrap();
        cmd.subshell(true).execute().unwrap();
        assert_eq!(optional("VAR").unwrap(), "0");

        let mut cmd: Command = "VAR=1".parse().unwrap();
        assert!(cmd.invert(true).execute().is_err());
        assert_eq!(optional("VAR").unwrap(), "1");

        let cmd: Command = "exit 1".parse().unwrap();
        assert!(cmd.execute().is_err());
    }

    #[test]
    fn no_line_in_error() {
        // regular command errors have line numbers
        let r = source::string("enable nonexistent");
        assert_err_re!(r, "^line 1: enable: nonexistent: not a shell builtin$");

        // skipped for custom commands because a line number isn't relevant
        let cmd: Command = "enable nonexistent".parse().unwrap();
        let r = cmd.execute();
        assert_err_re!(r, "^enable: nonexistent: not a shell builtin$");
    }

    #[test]
    fn current() {
        let r = current_command_string();
        assert_err_re!(r, "no running command");
        let r = current_command_name();
        assert_err_re!(r, "no running command");
    }

    #[test]
    fn invalid() {
        assert!(BashCommand::from_str("|| {").is_err());
    }
}