shi 0.1.5

A Rust library for crafting shell interfaces.
Documentation
//! A module for the shell portion of shi.
//!
//! This module includes all shell-related functionality and interfaces for using shi.
//! Namely, it exposes the `Shell` struct, which is the heart of shi. It makes use of `Command`'s
//! to create a shell interface.

use std::cell::RefCell;
use std::rc::Rc;

use rustyline::error::ReadlineError;

use crate::command::{
    builtin::{ExitCommand, HelpCommand, HelpTreeCommand, HistoryCommand},
    BaseCommand, Command,
};
use crate::command_set::CommandSet;
use crate::error::ShiError;
use crate::parser::{CommandType, Outcome, Parser};
use crate::readline::Readline;
use crate::Result;

/// The shell.
///
/// This gives the shell interface for shi. It is constructed and registered with commands.
/// Execution is done through a run-loop of input/output.
pub struct Shell<'a, S> {
    prompt: &'a str,
    // TODO: We likely should NOT be exporting these, even within the crate. Instead, we should add
    // public getters, perhaps?
    // We need Rc<RefCell> because:
    // * We need Rc because Shell is a self-referencing struct, in that the cmds field is referenced
    // by rl, so we need to allocate this at construction time (at runtime, on the heap) and share
    // references. This calls for Rc.
    // * Rc by itself is not mutable however, but we support adding commands to cmds. So we need
    // RefCell.
    pub(crate) cmds: Rc<RefCell<CommandSet<'a, S>>>,
    pub(crate) builtins: Rc<CommandSet<'a, Self>>,
    pub(crate) rl: Readline<'a, S>,
    parser: Parser,
    history_file: Option<&'a str>,
    state: S,
    pub(crate) terminate: bool,
}

impl<'a> Shell<'a, ()> {
    /// Constructs a new shell with the given prompt, and no state.
    ///
    /// # Arguments
    /// `prompt` - The prompt to display to the user.
    pub fn new(prompt: &'a str) -> Shell<()> {
        let cmds = Rc::new(RefCell::new(CommandSet::new()));
        let builtins = Rc::new(Shell::build_builtins());
        Shell {
            prompt,
            rl: Readline::new(Parser::new(), cmds.clone(), builtins.clone()),
            parser: Parser::new(),
            cmds,
            builtins,
            history_file: None,
            state: (),
            terminate: false,
        }
    }
}

impl<'a, S> Shell<'a, S> {
    /// Constructs the various builtin commands and returns a `CommandSet` of them.
    fn build_builtins() -> CommandSet<'a, Shell<'a, S>>
    where
        S: 'a,
    {
        let mut builtins: CommandSet<'a, Shell<'a, S>> = CommandSet::new();
        builtins.add(Command::new_leaf(HelpCommand::new()));
        builtins.add(Command::new_leaf(HelpTreeCommand::new()));
        builtins.add(Command::new_leaf(ExitCommand::new()));
        builtins.add(Command::new_leaf(HistoryCommand::new()));

        builtins
    }

    /// Constructs a new shell, with the given prompt & state.
    ///
    /// # Arguments
    /// `prompt` - The prompt to display to the user.
    /// `state` - The state that the `Shell` should persist across command invocations.
    pub fn new_with_state(prompt: &'a str, state: S) -> Shell<S>
    where
        S: 'a,
    {
        let cmds = Rc::new(RefCell::new(CommandSet::new()));
        let builtins = Rc::new(Shell::build_builtins());
        Shell {
            prompt,
            rl: Readline::new(Parser::new(), cmds.clone(), builtins.clone()),
            parser: Parser::new(),
            cmds,
            builtins,
            history_file: None,
            state,
            terminate: false,
        }
    }

    /// Registers the given command under this `Shell`.
    ///
    /// # Arguments
    /// `cmd` - The command to register.
    pub fn register(&mut self, cmd: Command<'a, S>) -> Result<()> {
        if self.cmds.borrow().contains(cmd.name()) {
            return Err(ShiError::AlreadyRegistered {
                cmd: cmd.name().to_string(),
            });
        }

        self.cmds.borrow_mut().add(cmd);

        Ok(())
    }

    // TODO: Should we be doing something similar to `rustyline` where we take `P: Path` or
    // whatever it is?
    /// Sets the history file & loads the history from it, if it exists already.
    ///
    /// This is necessary to call if one wishes for their command history to persist across
    /// sessions.
    ///
    /// # Arguments
    /// `history-file` - The path to the history file.
    pub fn set_and_load_history_file(&mut self, history_file: &'a str) -> Result<()> {
        self.rl.load_history(history_file)?;
        self.history_file = Some(history_file);
        Ok(())
    }

    /// Saves the history.
    ///
    /// This is effectively a no-op if no history file has been set.
    ///
    /// This must also be called to actually persist the current session's history. It is necessary
    /// to persist the history if one wishes to see it in future sessions.
    pub fn save_history(&mut self) -> Result<()> {
        if let Some(history_file) = self.history_file {
            self.rl.save_history(history_file)?;
        }
        Ok(())
    }

    pub(crate) fn parse<'b>(&mut self, line: &'b str) -> Outcome<'b> {
        self.parser.parse(line, &self.cmds.borrow(), &self.builtins)
    }

    /// Eval executes a single loop of the shell's run-loop.
    ///
    /// In other words, it takes a single input line and executes on it; `run()` is a loop over
    /// `eval()`.
    ///
    /// # Arguments
    /// `line` - The line to evaluate.
    pub fn eval(&mut self, line: &str) -> Result<String> {
        self.rl.add_history_entry(line);
        let outcome = self.parse(line);

        if !outcome.complete {
            return Err(outcome
                .error()
                .expect("incomplete parse, but failed to produce an error")); // This should never happen.
        }

        match outcome.cmd_type {
            CommandType::Custom => {
                // TODO: This recursive walking through the arguments when we pass this into the
                // ParentCommand is redundant, since we already did that work when we parsed
                // things. We should avoid doing this.
                if let Some(base_cmd_name) = outcome.cmd_path.first() {
                    if let Some(base_cmd) = self.cmds.borrow().get(base_cmd_name) {
                        let args: Vec<String> =
                            line.split(' ').skip(1).map(|s| s.to_string()).collect();
                        base_cmd.validate_args(&args)?;
                        return base_cmd.execute(&mut self.state, &args);
                    }
                }

                Err(ShiError::UnrecognizedCommand {
                    got: line.to_string(),
                })
            }
            CommandType::Builtin => {
                if let Some(base_cmd_name) = outcome.cmd_path.first() {
                    if let Some(base_cmd) = self.builtins.clone().get(base_cmd_name) {
                        let args: Vec<String> =
                            line.split(' ').skip(1).map(|s| s.to_string()).collect();
                        base_cmd.validate_args(&args)?;
                        return base_cmd.execute(self, &args);
                    }
                }

                Err(ShiError::UnrecognizedCommand {
                    got: line.to_string(),
                })
            }
            CommandType::Unknown => Err(outcome
                .error()
                .expect("parsed an Unknown, but failed to produce an error")), // This should never happen.
        }
    }

    /// Executes the shell's run-loop.
    ///
    /// This will run indefinitely until the user exits, otherwise terminates the shell or
    /// process or the shell encounters an error and stops.
    ///
    /// Note that invalid command invocations, e.g., nonexistent commands, are not considered fatal
    /// errors and do _not_ cause a return from this method.
    pub fn run(&mut self) -> Result<()> {
        while !self.terminate {
            let input = self.rl.readline(self.prompt);

            match input {
                Ok(line) => match self.eval(&line) {
                    Ok(output) => println!("{}", output),
                    Err(err) => println!("Error: {}", err),
                },
                Err(ReadlineError::Interrupted) => {
                    println!("-> CTRL+C; bye.");
                    break;
                }
                Err(ReadlineError::Eof) => {
                    println!("-> CTRL+D; bye.");
                    break;
                }
                Err(err) => {
                    println!("Error: {:?}", err);
                    break;
                }
            }
        }

        self.save_history()?;

        Ok(())
    }
}

#[cfg(test)]
pub mod test {
    use super::*;

    use crate::Result;
    use crate::{cmd, parent};

    use pretty_assertions::assert_eq;

    // TODO: Replace or add more tests that trigger the full codepath of the shell.
    #[test]
    fn issue6() -> Result<()> {
        let mut shell = Shell::new("| ");
        shell.register(parent!(
            "server",
            cmd!("listen", "Start listening on the given port", |_, args| {
                Ok(format!("start: {:?}", args))
            }),
            cmd!("unlisten", "stop listening", |_, args| {
                Ok(format!("stop: {:?}", args))
            })
        ))?;

        let output = shell.eval("server listen")?;
        assert_eq!(output, "start: []");

        let output = shell.eval("server listen foo")?;
        assert_eq!(output, "start: [\"foo\"]");

        let output = shell.eval("server unlisten")?;
        assert_eq!(output, "stop: []");

        let output = shell.eval("server unlisten foo")?;
        assert_eq!(output, "stop: [\"foo\"]");

        Ok(())
    }
}