shi 0.1.5

A Rust library for crafting shell interfaces.
Documentation
use std::borrow::Cow::{self, Borrowed, Owned};
use std::cell::RefCell;
use std::path::Path;
use std::rc::Rc;

use colored::*;

use rustyline::completion::{Completer, Pair};
use rustyline::highlight::{Highlighter, MatchingBracketHighlighter};
use rustyline::hint::{Hinter, HistoryHinter};
use rustyline::validate::{self, MatchingBracketValidator, Validator};
use rustyline::{Config, Context, Editor};
use rustyline_derive::Helper;

use crate::command::Completion;
use crate::command_set::CommandSet;
use crate::parser::Parser;
use crate::shell::Shell;
use crate::Result;

/// A wrapper around `rustyline::Editor`.
pub struct Readline<'a, S> {
    rl: Editor<ExecHelper<'a, S>>,
}

impl<'a, S> Readline<'a, S> {
    /// Constructs a new `Readline`.
    pub fn new(
        parser: Parser,
        cmds: Rc<RefCell<CommandSet<'a, S>>>,
        builtins: Rc<CommandSet<'a, Shell<'a, S>>>,
    ) -> Readline<'a, S> {
        let config = Config::builder()
            .completion_type(rustyline::CompletionType::List)
            .build();
        let mut rl = Editor::with_config(config);
        rl.set_helper(Some(ExecHelper::new(parser, cmds, builtins)));
        Readline { rl }
    }

    /// Loads the readline history from the given file.
    ///
    /// # Arguments
    /// `path` - The path to the history file to load history from.
    pub fn load_history<P: AsRef<Path> + ?Sized>(&mut self, path: &P) -> Result<()> {
        self.rl.load_history(path)?;
        Ok(())
    }

    /// Saves the history to the given file.
    ///
    /// # Arguments
    /// `path` - The path at which to save the history.
    pub fn save_history<P: AsRef<Path> + ?Sized>(&mut self, path: &P) -> Result<()> {
        self.rl.save_history(path)?;
        Ok(())
    }

    /// Adds a history entry to the history. This is done in memory. Persistence is achieved via
    /// `save_history()`.
    pub fn add_history_entry<E: AsRef<str> + Into<String>>(&mut self, line: E) -> bool {
        self.rl.add_history_entry(line)
    }

    /// Reads a line via the given prompt.
    ///
    /// # Arguments
    /// `prompt` - The prompt to display to the user.
    pub fn readline(&mut self, prompt: &str) -> rustyline::Result<String> {
        let mut input = self.rl.readline(prompt)?;
        // This due to the multi line validation in the ExecValidator. We need to remove the
        // newline in multiline input, as well as, and more importantly, the slash that denotes
        // multi-line input for the feature to be useful (otherwise any command taking multi-line
        // input will likely fail since a random slash would be in its argument).
        //
        // NOTE: This isn't actually great... if someone genuinely put this into their input string
        // we're gonna remove it... I'm not really happy about it, but I'm going to optimistically
        // assume this won't happen, at least not for a long time, and I'll fix it when it becomes
        // a problem.
        input = input.replace("\\\n", "");

        Ok(input)
    }

    /// Returns the readline `History`.
    ///
    /// Repeated, subsequent commands are not duplicated in the history.
    /// Invalid command invocations _are_ included in the history.
    /// May only be the commands executed in the current session, or it may also include prior
    /// sessions. This is dependent on whether `load_history()` was called for prior session
    /// histories.
    ///
    /// # Returns
    /// `rustyline::history::History` - The history of invoked commands.
    pub fn history(&self) -> &rustyline::history::History {
        self.rl.history()
    }
}

#[derive(Helper)]
/// An ExecHelper for supporting various `rustyline` features.
pub struct ExecHelper<'a, S> {
    completer: ExecCompleter<'a, S>,
    highlighter: MatchingBracketHighlighter,
    validator: ExecValidator,
    hinter: HistoryHinter,
    colored_prompt: String,
}

impl<'a, S> ExecHelper<'a, S> {
    /// Constructs an `ExecHelper`.
    fn new(
        parser: Parser,
        cmds: Rc<RefCell<CommandSet<'a, S>>>,
        builtins: Rc<CommandSet<'a, Shell<'a, S>>>,
    ) -> ExecHelper<'a, S> {
        ExecHelper {
            completer: ExecCompleter::new(parser, cmds, builtins),
            highlighter: MatchingBracketHighlighter::new(),
            validator: ExecValidator::new(),
            hinter: HistoryHinter {},
            colored_prompt: "| ".to_string(),
        }
    }
}

impl<'a, S> Completer for ExecHelper<'a, S> {
    type Candidate = Pair;

    fn complete(
        &self,
        line: &str,
        pos: usize,
        _: &Context<'_>,
    ) -> rustyline::Result<(usize, Vec<Pair>)> {
        Ok(self.completer.complete(line, pos))
    }
}

impl<'a, S> Hinter for ExecHelper<'a, S> {
    type Hint = String;

    fn hint(&self, line: &str, pos: usize, ctx: &Context<'_>) -> Option<String> {
        self.hinter.hint(line, pos, ctx)
    }
}

impl<'a, S> Highlighter for ExecHelper<'a, S> {
    fn highlight_prompt<'b, 's: 'b, 'p: 'b>(
        &'s self,
        prompt: &'p str,
        default: bool,
    ) -> Cow<'b, str> {
        if default {
            Borrowed(&self.colored_prompt)
        } else {
            Borrowed(prompt)
        }
    }

    fn highlight<'l>(&self, line: &'l str, pos: usize) -> Cow<'l, str> {
        self.highlighter.highlight(line, pos)
    }

    fn highlight_char(&self, line: &str, pos: usize) -> bool {
        self.highlighter.highlight_char(line, pos)
    }

    fn highlight_hint<'h>(&self, hint: &'h str) -> Cow<'h, str> {
        Owned(hint.black().bold().to_string())
    }
}

impl<'a, S> Validator for ExecHelper<'a, S> {
    fn validate(
        &self,
        ctx: &mut validate::ValidationContext,
    ) -> rustyline::Result<validate::ValidationResult> {
        self.validator.validate(ctx)
    }

    fn validate_while_typing(&self) -> bool {
        self.validator.validate_while_typing()
    }
}

// TODO: We should probably rename this. The 'Exec' prefix is meaningless I think.
struct ExecValidator {
    brackets: MatchingBracketValidator,
}

impl ExecValidator {
    fn new() -> ExecValidator {
        ExecValidator {
            brackets: MatchingBracketValidator::new(),
        }
    }

    fn is_currently_in_quote(&self, input: &str) -> bool {
        let input_iter = input.chars();

        let mut escaped = false;
        let mut currently_in_quote = false;
        let mut current_quote = ' ';

        // Walk through the string. There are three distinct classes of possibilities:
        // 1. We meet a quote.
        // In this case, what we do depends on if we've seen an unmatched quote character.
        // If we have, then this closes the quotation block, so we are _not_ in quote.
        // If not, then that means this starts a quotation block that hasn't been closed, which
        // would mean we _are_ in quote.
        // If this quote is escaped, then treat it identically to case 3 and ignore it, continuing
        // to the next character.
        //
        // 2. We meet a slash.
        // This implies escaping. Everytime we see a slash, we toggle the escaped flag. This way, a
        // single slash, '\', makes us ready to escape the next character. Two slashes, '\\', makes
        // us treat the next character normally. Three, '\\\', makes us escape the next character.
        // So on, so forth. The escape flag is toggled off if we meet a character that is not
        // slash.
        //
        // 3. Neither of the above. Continue to the next character.
        //
        //
        // NOTE: The algorithm above only considers a quotation block closed if it finds a
        // quotation character of the _same kind_. Therefore, the string: "hello world' is _not_
        // closed!
        // NOTE: A quotation character of a different class is ignored as if it was escaped if it
        // is contained between quote characters of the other class. e.g. "'" is valid, even though
        // `'` (single-quote character) is not technically balanced..
        for ch in input_iter {
            if ch == '\\' {
                escaped = !escaped;
                continue;
            }

            let is_quote = ch == '\"' || ch == '\'';
            if is_quote && !escaped {
                if currently_in_quote && ch == current_quote {
                    // This implies we just closed a quotation block.
                    // Hence we are no longer in quotes:
                    currently_in_quote = false;
                    // And the current quote is back to non-quote:
                    current_quote = ' ';
                } else if currently_in_quote && ch != current_quote {
                    // We found another quote character, but it doesn't match the quote we're
                    // currently in scope for, so ignore it:
                    continue;
                } else {
                    // We're not in a quote, but we found a quote character.
                    // Therefore, we just entered a quotation block:
                    currently_in_quote = true;
                    current_quote = ch;
                }
            }

            // Regardless of what happens, we just saw a character that is not a slash. So we
            // are not escaped anymore.
            escaped = false;
        }

        currently_in_quote
    }

    #[allow(clippy::unnecessary_wraps)]
    fn validate_quotes(&self, cur_input: &str) -> rustyline::Result<validate::ValidationResult> {
        if self.is_currently_in_quote(cur_input) {
            return Ok(validate::ValidationResult::Incomplete);
        }

        Ok(validate::ValidationResult::Valid(None))
    }

    // validate_multiline effectively looks simply for a '\' at the end of the line, indicating
    // that it is a multi-line input.
    // Technically, one may say this is not perfectly 'correct'. Generally, we want to follow what
    // bash does simply cause we assume that's what users are most familiar with and therefore
    // expect from us. However, bash, in this case, will actually _not_ include the newline when
    // you go to the next line, and also removes the slash.
    //
    // We don't... that's actually really bad. It makes it virtually useless.
    // ...Which is why we remove it later (see Readline::readline()). But I'm not happy about this
    // whatsoever, because ideally that removal should happen here, in the validator... not much we
    // can do about it though, since rustyline doesn't make the input line mutable here. Plus, what
    // we can do in Readline::readline() is limited (see the comment in that function on a
    // drawback).
    #[allow(clippy::unnecessary_wraps)]
    fn validate_multiline(&self, cur_input: &str) -> rustyline::Result<validate::ValidationResult> {
        if let Some('\\') = cur_input.chars().last() {
            return Ok(validate::ValidationResult::Incomplete);
        }

        Ok(validate::ValidationResult::Valid(None))
    }

    fn merge_validation_results(
        &self,
        reses: Vec<validate::ValidationResult>,
    ) -> validate::ValidationResult {
        for res in reses.into_iter() {
            match res {
                validate::ValidationResult::Valid(_) => continue,
                _ => return res,
            };
        }

        validate::ValidationResult::Valid(None)
    }
}

impl Validator for ExecValidator {
    fn validate(
        &self,
        ctx: &mut validate::ValidationContext,
    ) -> rustyline::Result<validate::ValidationResult> {
        Ok(self.merge_validation_results(vec![
            self.brackets.validate(ctx)?,
            self.validate_quotes(ctx.input())?,
            self.validate_multiline(ctx.input())?,
        ]))
    }

    fn validate_while_typing(&self) -> bool {
        self.brackets.validate_while_typing()
    }
}

/// ExecCompleter enables command completion in the shell.
struct ExecCompleter<'a, S> {
    parser: Parser,
    cmds: Rc<RefCell<CommandSet<'a, S>>>,
    builtins: Rc<CommandSet<'a, Shell<'a, S>>>,
}

impl<'a, S> ExecCompleter<'a, S> {
    /// Constructs a new `ExecCompleter`.
    ///
    /// # Arguments
    /// `parser` - The parser to use for command completion.
    /// `cmds` - The custom commands to complete for.
    /// `builtins` - The builtins to complete for.
    fn new(
        parser: Parser,
        cmds: Rc<RefCell<CommandSet<'a, S>>>,
        builtins: Rc<CommandSet<'a, Shell<'a, S>>>,
    ) -> ExecCompleter<'a, S> {
        ExecCompleter {
            parser,
            cmds,
            builtins,
        }
    }

    /// Offers completion candidates for a line.
    ///
    /// Tries to mimic to some degree, the completion behavior in bash shells.
    ///
    /// In particular, this means that `pos` values that are not at the end of the line behave as
    /// if the portion of the line prior to it is the entirety of the line. e.g.:
    ///
    /// ```bash
    /// $ happ|iness
    ///       ^ Assuming this is the cursor position...
    /// $ happinessiness # Is the completion result.
    /// ```
    ///
    /// # Arguments
    /// `line` - The line to try offering completion candidates for.
    /// `pos` - The position of the cursor on that line.
    ///
    /// # Returns
    /// `rustyline::Result<(usize, Vec<Pair>)>` - A result of a position & completion results to
    /// present.
    fn complete(&self, line: &str, pos: usize) -> (usize, Vec<Pair>) {
        // First, let's get the slice of the line leading up to the position, because really,
        // that's what we actually care about when trying to determine the completion.
        let partial = match line.get(..pos) {
            Some(p) => p,
            None => {
                // This shouldn't ever happen, as I believe `pos` should always be within bounds of
                // `line`. However, it doesn't hurt to be safe.
                return (0, Vec::new());
            }
        };

        // Now, try parsing what the user wants us to complete.
        let outcome = self
            .parser
            .parse(partial, &self.cmds.borrow(), &self.builtins);

        // If the parse was complete, then we've gone down to a leaf command, and all we have left
        // is to try autocompletions on the arguments.
        if outcome.complete {
            match outcome.leaf_completion {
                None => {
                    return (pos, vec![]);
                }
                Some(completion) => match completion {
                    Completion::Nothing => {
                        return (pos, vec![]);
                    }
                    Completion::PartialArgCompletion(arg_suffixes) => {
                        return (
                            pos,
                            arg_suffixes
                                .iter()
                                .map(|suffix_poss| Pair {
                                    display: suffix_poss.clone(),
                                    replacement: suffix_poss.clone(),
                                })
                                .collect(),
                        );
                    }
                    Completion::Possibilities(possibilities) => {
                        // Although we'd like to immediately get around to giving back completions, what's
                        // important is that we pad it with a space delimiter in case the user tabs when their
                        // cursor is adjacent to the argument, so we don't complete 'foo bar' to 'foo barbaz'
                        // and instead get 'foo bar baz'.
                        // Note how we don't do this for partial arg completions, since this are
                        // meant to be concatenated.
                        if !partial.ends_with(' ') {
                            return (
                                pos,
                                vec![Pair {
                                    display: String::from(" "),
                                    replacement: String::from(" "),
                                }],
                            );
                        }

                        return (
                            pos,
                            possibilities
                                .iter()
                                .map(|poss| Pair {
                                    display: poss.clone(),
                                    replacement: poss.clone(),
                                })
                                .collect(),
                        );
                    }
                },
            }
        }

        // The outcome includes what the parser would have allowed to have existed in the string.
        // Of these possibilities, some are better matches than others. Let's rank them as such by
        // finding those that share the first of the remaining tokens (or empty string if empty).
        let prefix = if let Some(first_token) = outcome.remaining.first() {
            first_token
        } else {
            // If the remaining is empty and we have an incomplete parse, that implies that the
            // user has thus far entered something valid but there are more subcommands to provide.
            // If the user then tabs to get a completion, it implies that they want to add a
            // subcommand. Before we can do that, we need a space delimiter, so that should be our
            // provided completion if it does not yet exist!
            //
            // ... with one gotcha. If the line is completely empty, we obviously should not expect
            // a space. The start of the line is itself a delimiter of sorts.
            if partial.is_empty() {
                ""
            } else if !partial.ends_with(' ') {
                // As said before, complete this as a space so that the next attempt at tab
                // completion gives the results the user likely actually wanted to see.
                return (
                    pos,
                    vec![Pair {
                        display: String::from(" "),
                        replacement: String::from(" "),
                    }],
                );
            } else {
                // Otherwise, the user already has the delimiter. So now we should provide any and
                // all subsequent subcommands.
                ""
            }
        };

        // So now, filter out those that have that aforementioned token as a prefix. And once we
        // have that, grab the suffix for completion.
        let candidates = outcome.possibilities.into_iter().filter_map(|poss| {
            if poss.starts_with(prefix) {
                // This really should never fail to get the remaining suffix, since the condition
                // guarantees that the prefix exists... but no harm in being safe if we can.
                poss.get(prefix.len()..).map(|s| s.to_string())
            } else {
                None
            }
        });

        // Finally, map the candidates to `Pair`'s, which is what the Completer interface wants.
        let pairs: Vec<Pair> = candidates
            .map(|candidate| Pair {
                display: candidate.to_string(),
                // Since we set our position of replacement to pos, we can just get away with
                // returning the suffix of the candidate to append from there.
                replacement: candidate,
            })
            .collect();

        (pos, pairs)
    }
}

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

    mod completions {
        use super::*;
        use crate::parser::test::make_parser_cmds;
        use crate::parser::Parser;

        use pretty_assertions::assert_eq;

        fn make_completer<'a>() -> ExecCompleter<'a, ()> {
            let (cmds, builtins) = make_parser_cmds();

            // Wrap these to satisfy the type checker.
            let cmds = Rc::new(RefCell::new(cmds));
            let builtins = Rc::new(builtins);

            ExecCompleter::new(Parser::new(), cmds, builtins)
        }

        fn test_completion(
            completer: ExecCompleter<'_, ()>,
            line: &str,
            pos: usize,
            expected_pairs: Vec<Pair>,
        ) {
            let cmpl_res = completer.complete(line, pos);
            let (cmpl_pos, pairs) = cmpl_res;
            // We should always be returning a position that is the given position.
            assert_eq!(cmpl_pos, pos, "mismatched positions");

            assert_eq!(
                pairs.len(),
                expected_pairs.len(),
                "mismatched number of completions"
            );

            for (p1, p2) in pairs.iter().zip(expected_pairs.iter()) {
                assert_eq!(p1.display, p2.display, "non-matching display strings");
                assert_eq!(
                    p1.replacement, p2.replacement,
                    "non-matching replacement strings"
                );
            }
        }

        #[test]
        fn simple() {
            let completer = make_completer();

            let line = "grau";

            test_completion(
                completer,
                line,
                line.len(),
                vec![Pair {
                    display: "lt-c".to_string(),
                    replacement: "lt-c".to_string(),
                }],
            )
        }

        #[test]
        fn no_matches() {
            let completer = make_completer();

            let line = "idontexistlol";

            test_completion(completer, line, line.len(), vec![])
        }

        #[test]
        fn multiple_matches() {
            let completer = make_completer();

            let line = "conflict-";

            test_completion(
                completer,
                line,
                line.len(),
                vec![
                    Pair {
                        display: "tie".to_string(),
                        replacement: "tie".to_string(),
                    },
                    Pair {
                        display: "builtin-longer-match-but-still-loses".to_string(),
                        replacement: "builtin-longer-match-but-still-loses".to_string(),
                    },
                    Pair {
                        display: "custom-wins".to_string(),
                        replacement: "custom-wins".to_string(),
                    },
                ],
            )
        }

        #[test]
        fn nested() {
            let completer = make_completer();

            let line = "foo-c qu";

            test_completion(
                completer,
                line,
                line.len(),
                vec![Pair {
                    display: "x-c".to_string(),
                    replacement: "x-c".to_string(),
                }],
            )
        }

        #[test]
        fn already_completed() {
            let completer = make_completer();

            let line = "foo-c qux-c quux-c";

            test_completion(completer, line, line.len(), vec![])
        }

        #[test]
        fn completely_blank_for_last_command() {
            let completer = make_completer();

            let line = "foo-c qux-c ";

            test_completion(
                completer,
                line,
                line.len(),
                vec![
                    Pair {
                        display: "quux-c".to_string(),
                        replacement: "quux-c".to_string(),
                    },
                    Pair {
                        display: "corge-c".to_string(),
                        replacement: "corge-c".to_string(),
                    },
                ],
            )
        }

        #[test]
        fn completion_includes_a_space() {
            let completer = make_completer();

            let line = "foo-c qux-c";

            test_completion(
                completer,
                line,
                line.len(),
                vec![Pair {
                    display: " ".to_string(),
                    replacement: " ".to_string(),
                }],
            )
        }

        #[test]
        fn nothing_typed() {
            let completer = make_completer();

            let line = "";

            test_completion(
                completer,
                line,
                line.len(),
                vec![
                    Pair {
                        display: "foo-c".to_string(),
                        replacement: "foo-c".to_string(),
                    },
                    Pair {
                        display: "grault-c".to_string(),
                        replacement: "grault-c".to_string(),
                    },
                    Pair {
                        display: "conflict-tie".to_string(),
                        replacement: "conflict-tie".to_string(),
                    },
                    Pair {
                        display: "conflict-builtin-longer-match-but-still-loses".to_string(),
                        replacement: "conflict-builtin-longer-match-but-still-loses".to_string(),
                    },
                    Pair {
                        display: "conflict-custom-wins".to_string(),
                        replacement: "conflict-custom-wins".to_string(),
                    },
                ],
            )
        }

        #[test]
        fn non_end_pos() {
            let completer = make_completer();

            let line = "grault-c";

            test_completion(
                completer,
                line, // 'grault-c'
                3,    //     ^
                vec![Pair {
                    display: "ult-c".to_string(),
                    replacement: "ult-c".to_string(),
                }],
            )
        }

        #[test]
        fn nested_non_end_pos() {
            let completer = make_completer();

            let line = "foo-c qux-c quux-c";

            test_completion(
                completer,
                line, // 'foo-c qux-c quux-c'
                8,    //          ^
                vec![Pair {
                    display: "x-c".to_string(),
                    replacement: "x-c".to_string(),
                }],
            )
        }
    }

    mod validator {
        use super::*;

        #[derive(Debug, PartialEq)]
        enum TestValidationResult {
            Valid,
            Invalid,
            Incomplete,
        }

        impl From<validate::ValidationResult> for TestValidationResult {
            fn from(res: validate::ValidationResult) -> Self {
                match res {
                    validate::ValidationResult::Valid(_) => TestValidationResult::Valid,
                    validate::ValidationResult::Invalid(_) => TestValidationResult::Invalid,
                    validate::ValidationResult::Incomplete => TestValidationResult::Incomplete,
                    // ValidationResult is marked as #[non_exhaustive], so we need to do this. We
                    // _want_ to panic, because if Rustyline adds a new case, we'd like to know about
                    // it via a test failure.
                    _ => panic!("unexpected ValidationResult kind"),
                }
            }
        }

        fn validation_res_eq(a: validate::ValidationResult, b: validate::ValidationResult) {
            assert_eq!(TestValidationResult::from(a), TestValidationResult::from(b));
        }

        fn check_validation_res(
            res: rustyline::Result<validate::ValidationResult>,
            expected: validate::ValidationResult,
        ) {
            match res {
                Ok(res) => {
                    validation_res_eq(res, expected);
                }
                Err(err) => {
                    panic!("did not expect an error during validation: {}", err);
                }
            }
        }

        fn test_validation_quotes(input: &str, expected_validity: validate::ValidationResult) {
            let validator = ExecValidator::new();

            // We have to call validate_quotes() instead of validate(), because validate() needs to
            // take a ValidationResult, which has no public constructor.
            let validation_res = validator.validate_quotes(input);

            check_validation_res(validation_res, expected_validity);
        }

        fn test_validation_multiline(input: &str, expected_validity: validate::ValidationResult) {
            let validator = ExecValidator::new();

            let validation_res = validator.validate_multiline(input);

            check_validation_res(validation_res, expected_validity);
        }

        #[test]
        fn one_single_quote() {
            test_validation_quotes("\'", validate::ValidationResult::Incomplete);
        }

        #[test]
        fn one_double_quote() {
            test_validation_quotes("\"", validate::ValidationResult::Incomplete);
        }

        #[test]
        fn balanced_single() {
            test_validation_quotes("\'\'", validate::ValidationResult::Valid(None));
        }

        #[test]
        fn balanced_double() {
            test_validation_quotes("\"\"", validate::ValidationResult::Valid(None));
        }

        #[test]
        fn unbalanced_but_escaped_is_ok() {
            test_validation_quotes("\\'", validate::ValidationResult::Valid(None));
        }

        #[test]
        fn balanced_but_mismatched_quote_types_is_incomplete() {
            test_validation_quotes("\'\"", validate::ValidationResult::Incomplete);
        }

        #[test]
        fn nested_quotes_unbalanced_still_incomplete() {
            test_validation_quotes("\'\"\'\"\'", validate::ValidationResult::Incomplete);
        }

        #[test]
        fn overlapping_but_balanced_quotes_is_incomplete() {
            test_validation_quotes("\' \" \' \"", validate::ValidationResult::Incomplete);
        }

        #[test]
        fn multiple_escapes_valid() {
            // This is actually the literal string `\\\'`, meaning the last quote is escaped and
            // therefore valid.
            test_validation_quotes("\\\\\\'", validate::ValidationResult::Valid(None));
        }

        #[test]
        fn multiple_escapes_incomplete() {
            // This is actually the literal string `\\\\'`, meaning the last quote is unescaped and
            // therefore incomplete.
            test_validation_quotes("\\\\\\\\'", validate::ValidationResult::Incomplete);
        }

        #[test]
        fn closed_quote_block_with_unmatched_quote_inside_is_valid() {
            test_validation_quotes("\"'\"", validate::ValidationResult::Valid(None));
        }

        #[test]
        fn many_quoted_blocks() {
            test_validation_quotes(
                "\'hey how are you?\' \"im doing ok\" \'please thank me for asking\'",
                validate::ValidationResult::Valid(None),
            );

            test_validation_quotes(
                "'hey how are you?' \"im doing ok\" \\\\'please thank me for asking'",
                validate::ValidationResult::Valid(None),
            );
        }

        #[test]
        fn slash_at_end_is_incomplete() {
            test_validation_multiline("hello world\\", validate::ValidationResult::Incomplete);
        }

        #[test]
        fn slash_with_trailing_character_is_complete() {
            test_validation_multiline("hello world\\g", validate::ValidationResult::Valid(None));
        }

        #[test]
        fn slash_with_trailing_space_is_complete() {
            test_validation_multiline("hello world\\ ", validate::ValidationResult::Valid(None));
        }

        #[test]
        fn no_issues_is_complete() {
            test_validation_multiline("hello world", validate::ValidationResult::Valid(None));
        }
    }
}