slaps 0.2.3

Interactive shell interface using your existing Clap/StructOpt config
Documentation
/*
 * lib.rs is part of slaps.
 * Copyright (C) 2020 rd <slaps@megane.space>
 * License: WTFPL
 */

//! Add an interactive shell mode to your Rust command-line application using your
//! existing [`clap`]/[`structopt`] configuration.
//!
//! [`Clap`]: https://crates.io/crates/clap
//! [`structopt`]: https://crates.io/crates/structopt

// Deny unsafe code and enforce strict linting.
#![deny(unsafe_code)]
#![warn(clippy::all)]
#![warn(clippy::pedantic)]
#![warn(missing_docs)]
#![warn(missing_copy_implementations)]
#![warn(missing_debug_implementations)]

mod clap;
mod config;
mod error;
#[doc(hidden)]
pub mod shlex;

pub use config::{ColorMode, CompletionType, Config};
pub use error::{ClapError, Error, ErrorKind, ExecutionError, MismatchedQuotes, ReadlineError};
use rustyline::Editor;
use std::collections::hash_map::DefaultHasher;
use std::collections::HashMap;
use std::ffi::OsStr;
use std::fmt::{Debug, Formatter};
use std::hash::{Hash, Hasher};

/// Handler function for a command.
type Handler<'a> = Box<dyn 'a + Fn(&clap::ArgMatches) -> Result<(), ExecutionError> + Send + Sync>;

/// Interactive shell mode using a `clap`/`structopt` configuration.
pub struct Slaps<'a, 'b> {
    config: Config<'b>,
    editor: Editor<clap::Matcher<'a, 'b>>,
    handlers: HashMap<u64, Handler<'b>>,
}

impl<'a, 'b> Slaps<'a, 'b> {
    /// Creates a new interactive app using the default configuration.
    ///
    /// # Examples
    /// ```
    /// use slaps::Slaps;
    ///
    /// let slaps = Slaps::with_name("slaps");
    /// ```
    #[must_use]
    pub fn with_name(name: &str) -> Self {
        Self::with_name_and_config(name, Config::default())
    }

    /// Creates a new interactive app with the given `Config`.
    ///
    /// # Examples
    /// ```
    /// use slaps::{Config, ColorMode, Slaps};
    ///
    /// let slaps = Slaps::with_name_and_config("slaps", Config::default()
    ///     .color_mode(ColorMode::Disabled));
    /// ```
    #[must_use]
    pub fn with_name_and_config(name: &str, config: Config<'b>) -> Slaps<'a, 'b> {
        use ::clap::App;

        let mut editor = Editor::with_config(config.into());
        editor.set_helper(Some(clap::Matcher::with_name_and_config(name, config)));

        Slaps {
            config,
            editor,
            handlers: HashMap::new(),
        }
        .command(
            App::new("quit").alias("exit").about("Exits the program"),
            |_| Err(ExecutionError::GracefulExit),
        )
    }

    /// Registers a Clap subcommand configuration with Slaps.
    ///
    /// # Examples
    /// ```
    /// use clap::App;
    /// use slaps::Slaps;
    ///
    /// let slaps = Slaps::with_name("slaps")
    ///     .command(App::new("slap"), |matches| {println!("{:?}", matches); Ok(())} );
    /// ```
    #[must_use]
    pub fn command<T: 'b>(mut self, command: clap::App<'a, 'b>, handler: T) -> Self
    where
        T: Fn(&clap::ArgMatches) -> Result<(), ExecutionError> + Send + Sync,
    {
        self.handlers
            .insert(calculate_hash(&command.p.meta.name), Box::new(handler));
        self.editor.helper_mut().unwrap().register_command(command);
        self
    }

    /// Prompts the user for a `String` using the default prompt.
    ///
    /// If prompt is None, uses the default from the [`Config`].
    ///
    /// # Errors
    /// May return a [`ReadlineError`] from the internal readline implementation.
    pub fn prompt_line(&mut self, prompt: Option<&str>) -> Result<String, ReadlineError> {
        Ok(self.editor.readline(prompt.unwrap_or(self.config.prompt))?)
    }

    /// Prompts the user for a password.
    ///
    /// If prompt is None, uses the default from the [`Config`].
    ///
    /// # Errors
    /// May return a [`ReadlineError`] from the internal readline implementation.
    pub fn prompt_password(&self, prompt: Option<&str>) -> Result<String, ReadlineError> {
        Ok(rpassword::prompt_password_stderr(
            prompt.unwrap_or(self.config.password_prompt),
        )?)
    }

    /// Parses a string directly into a [`clap::ArgMatches`] object.
    ///
    /// # Errors
    /// - `MismatchedQuote` parsing failed due to mismatched quotation marks within the input.
    ///
    /// # Examples
    /// ```
    /// use clap::{App, Arg};
    /// use slaps::Slaps;
    ///
    /// let command = App::new("slaps").arg(Arg::with_name("that").long("that").takes_value(true));
    /// let mut slaps = Slaps::with_name("slaps").command(command, |args| Ok(()));
    /// let matches = slaps.get_matches("slaps --that arg").unwrap();
    ///
    /// assert_eq!(matches.subcommand_name(), Some("slaps"));
    /// assert_eq!(matches.subcommand_matches("slaps").unwrap().value_of("that"), Some("arg"));
    /// ```
    pub fn get_matches<T: AsRef<str>>(&mut self, input: T) -> Result<clap::ArgMatches, Error> {
        let words = shlex::split(input.as_ref())?;
        Ok(self.editor.helper_mut().unwrap().get_matches(&words)?)
    }

    /// Starts the main interactive loop, prompting the user for input repeatedly.
    ///
    /// # Errors
    /// Will return an error when it fails to read more input.
    pub fn run(mut self) -> Result<(), Error> {
        use rustyline::error::ReadlineError as RustylineError;
        loop {
            let input = match self.editor.readline(self.config.prompt) {
                Ok(i) => i,
                Err(e) => {
                    return match e {
                        RustylineError::Eof | RustylineError::Interrupted => {
                            Err(ExecutionError::GracefulExit.into())
                        }
                        _ => Err(e.into()),
                    }
                }
            };

            let fields = match shlex::split(&input) {
                Ok(f) => f,
                Err(e) => {
                    eprintln!("{}", e);
                    continue;
                }
            };

            match self.handle_matches(&fields) {
                Err(Error::ClapError(e)) => match e.kind {
                    clap::ErrorKind::Io | ErrorKind::Format => return Err(Error::ClapError(e)),
                    _ => eprintln!("{}", e),
                },
                Ok(r) => eprintln!("t {:?}", r),
                Err(e) => eprintln!("{}", e),
            };
        }
    }

    /// Matches a command by parsing command-line arguments from the given input, and executes its
    /// associated handler.
    ///
    /// Useful for handling commands from `std::env::args` before switching to interactive mode.
    ///
    /// # Errors
    /// - [`ClapError`] - the input did not specify a command or failed to parse arguments.
    /// - [`ExecutionError`] - the handler returned an error.
    ///
    /// # Examples
    /// ```
    /// use slaps::{Config, ExecutionError, Slaps, Error};
    /// use clap::{App, Arg, ErrorKind};
    ///
    /// let command = App::new("slaps").arg(
    /// Arg::with_name("this")
    ///     .long("this")
    ///     .takes_value(true)
    ///     .required(true),
    /// );
    ///
    /// let mut slaps = Slaps::with_name("slaps").command(command, |args| {
    /// # assert_eq!(args.value_of("this"), Some("arg"));
    ///     Err(ExecutionError::GracefulExit)
    /// });
    /// let args = ["prog", "slaps", "--this", "arg"];
    /// let result = slaps.handle_matches(&args[1..]);
    ///
    /// match result {
    ///     Err(Error::ClapError(ref e)) => match e.kind {
    ///         ErrorKind::Io | ErrorKind::Format => eprintln!("I/O error: {}", e),
    ///         _ => eprintln!("Error while parsing arguments: {}", e),
    ///     },
    ///     Ok(_) => eprintln!("Command succeeded."),
    ///     Err(ref e) => eprintln!("Command handler failed to execute: {}", e)
    /// }
    ///
    /// # assert!(result.is_err());
    /// # assert!(matches!(
    /// #        result,
    /// #        Err(Error::ExecutionError(ExecutionError::GracefulExit))
    /// #    ));
    /// ```
    pub fn handle_matches<A: AsRef<OsStr>>(&mut self, input: &[A]) -> Result<(), Error> {
        let matches = self.editor.helper_mut().unwrap().get_matches(input)?;
        if let Some(subcommand) = matches.subcommand_name() {
            if let (Some(handler), Some(subcommand_matches)) = (
                self.handlers.get_mut(&calculate_hash(&subcommand)),
                matches.subcommand_matches(subcommand),
            ) {
                handler(subcommand_matches)?;
            }
        }
        Ok(())
    }
}

impl Debug for Slaps<'_, '_> {
    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
        write!(
            f,
            "Slaps {{config = {:?}, handlers = {:?}}}",
            self.config,
            self.handlers.keys()
        )
    }
}

// Hack to avoid copying Strings into the handlers hashmap.
fn calculate_hash<T: Hash>(t: &T) -> u64 {
    let mut s = DefaultHasher::new();
    t.hash(&mut s);
    s.finish()
}

#[cfg(test)]
mod tests {
    use crate::config::ColorMode::Disabled;
    use crate::config::CompletionType::List;
    use crate::shlex::split;
    use crate::{Config, Slaps};

    #[test]
    fn test_sets_rustyline_config() {
        use rustyline::config::Configurer;
        use rustyline::config::OutputStreamType;
        use rustyline::ColorMode;
        use rustyline::CompletionType;

        let config = Config::default().color_mode(Disabled).completion_type(List);

        let mut slaps = Slaps::with_name_and_config("slaps", config);
        let readline_config = slaps.editor.config_mut();
        assert_eq!(readline_config.color_mode(), ColorMode::Disabled);
        assert_eq!(readline_config.completion_type(), CompletionType::List);
        assert_eq!(readline_config.auto_add_history(), config.history_auto_add);
        assert_eq!(readline_config.tab_stop(), 4);
        assert_eq!(readline_config.output_stream(), OutputStreamType::Stderr);
    }

    #[test]
    fn test_propagates_clap_errors() {
        let app = ::clap::App::new("slaps").arg(
            ::clap::Arg::with_name("this")
                .long("this")
                .takes_value(true)
                .required(true),
        );
        let config = Config::default().highlighter_underline_word_under_cursor(true);
        let mut slaps =
            Slaps::with_name_and_config("slaps", config).command(app, |_| unreachable!());
        let result = slaps.handle_matches(&split("slaps --that arg").unwrap());

        assert!(result.is_err());
        assert!(matches!(result, Err(crate::Error::ClapError(_))));
    }
}