shi/
readline.rs

1use std::borrow::Cow::{self, Borrowed, Owned};
2use std::cell::RefCell;
3use std::path::Path;
4use std::rc::Rc;
5
6use colored::*;
7
8use rustyline::completion::{Completer, Pair};
9use rustyline::highlight::{Highlighter, MatchingBracketHighlighter};
10use rustyline::hint::{Hinter, HistoryHinter};
11use rustyline::validate::{self, MatchingBracketValidator, Validator};
12use rustyline::{Config, Context, Editor};
13use rustyline_derive::Helper;
14
15use crate::command::Completion;
16use crate::command_set::CommandSet;
17use crate::parser::Parser;
18use crate::shell::Shell;
19use crate::Result;
20
21/// A wrapper around `rustyline::Editor`.
22pub struct Readline<'a, S> {
23    rl: Editor<ExecHelper<'a, S>>,
24}
25
26impl<'a, S> Readline<'a, S> {
27    /// Constructs a new `Readline`.
28    pub fn new(
29        parser: Parser,
30        cmds: Rc<RefCell<CommandSet<'a, S>>>,
31        builtins: Rc<CommandSet<'a, Shell<'a, S>>>,
32    ) -> Readline<'a, S> {
33        let config = Config::builder()
34            .completion_type(rustyline::CompletionType::List)
35            .build();
36        let mut rl = Editor::with_config(config);
37        rl.set_helper(Some(ExecHelper::new(parser, cmds, builtins)));
38        Readline { rl }
39    }
40
41    /// Loads the readline history from the given file.
42    ///
43    /// # Arguments
44    /// `path` - The path to the history file to load history from.
45    pub fn load_history<P: AsRef<Path> + ?Sized>(&mut self, path: &P) -> Result<()> {
46        self.rl.load_history(path)?;
47        Ok(())
48    }
49
50    /// Saves the history to the given file.
51    ///
52    /// # Arguments
53    /// `path` - The path at which to save the history.
54    pub fn save_history<P: AsRef<Path> + ?Sized>(&mut self, path: &P) -> Result<()> {
55        self.rl.save_history(path)?;
56        Ok(())
57    }
58
59    /// Adds a history entry to the history. This is done in memory. Persistence is achieved via
60    /// `save_history()`.
61    pub fn add_history_entry<E: AsRef<str> + Into<String>>(&mut self, line: E) -> bool {
62        self.rl.add_history_entry(line)
63    }
64
65    /// Reads a line via the given prompt.
66    ///
67    /// # Arguments
68    /// `prompt` - The prompt to display to the user.
69    pub fn readline(&mut self, prompt: &str) -> rustyline::Result<String> {
70        let mut input = self.rl.readline(prompt)?;
71        // This due to the multi line validation in the ExecValidator. We need to remove the
72        // newline in multiline input, as well as, and more importantly, the slash that denotes
73        // multi-line input for the feature to be useful (otherwise any command taking multi-line
74        // input will likely fail since a random slash would be in its argument).
75        //
76        // NOTE: This isn't actually great... if someone genuinely put this into their input string
77        // we're gonna remove it... I'm not really happy about it, but I'm going to optimistically
78        // assume this won't happen, at least not for a long time, and I'll fix it when it becomes
79        // a problem.
80        input = input.replace("\\\n", "");
81
82        Ok(input)
83    }
84
85    /// Returns the readline `History`.
86    ///
87    /// Repeated, subsequent commands are not duplicated in the history.
88    /// Invalid command invocations _are_ included in the history.
89    /// May only be the commands executed in the current session, or it may also include prior
90    /// sessions. This is dependent on whether `load_history()` was called for prior session
91    /// histories.
92    ///
93    /// # Returns
94    /// `rustyline::history::History` - The history of invoked commands.
95    pub fn history(&self) -> &rustyline::history::History {
96        self.rl.history()
97    }
98}
99
100#[derive(Helper)]
101/// An ExecHelper for supporting various `rustyline` features.
102pub struct ExecHelper<'a, S> {
103    completer: ExecCompleter<'a, S>,
104    highlighter: MatchingBracketHighlighter,
105    validator: ExecValidator,
106    hinter: HistoryHinter,
107    colored_prompt: String,
108}
109
110impl<'a, S> ExecHelper<'a, S> {
111    /// Constructs an `ExecHelper`.
112    fn new(
113        parser: Parser,
114        cmds: Rc<RefCell<CommandSet<'a, S>>>,
115        builtins: Rc<CommandSet<'a, Shell<'a, S>>>,
116    ) -> ExecHelper<'a, S> {
117        ExecHelper {
118            completer: ExecCompleter::new(parser, cmds, builtins),
119            highlighter: MatchingBracketHighlighter::new(),
120            validator: ExecValidator::new(),
121            hinter: HistoryHinter {},
122            colored_prompt: "| ".to_string(),
123        }
124    }
125}
126
127impl<'a, S> Completer for ExecHelper<'a, S> {
128    type Candidate = Pair;
129
130    fn complete(
131        &self,
132        line: &str,
133        pos: usize,
134        _: &Context<'_>,
135    ) -> rustyline::Result<(usize, Vec<Pair>)> {
136        Ok(self.completer.complete(line, pos))
137    }
138}
139
140impl<'a, S> Hinter for ExecHelper<'a, S> {
141    type Hint = String;
142
143    fn hint(&self, line: &str, pos: usize, ctx: &Context<'_>) -> Option<String> {
144        self.hinter.hint(line, pos, ctx)
145    }
146}
147
148impl<'a, S> Highlighter for ExecHelper<'a, S> {
149    fn highlight_prompt<'b, 's: 'b, 'p: 'b>(
150        &'s self,
151        prompt: &'p str,
152        default: bool,
153    ) -> Cow<'b, str> {
154        if default {
155            Borrowed(&self.colored_prompt)
156        } else {
157            Borrowed(prompt)
158        }
159    }
160
161    fn highlight<'l>(&self, line: &'l str, pos: usize) -> Cow<'l, str> {
162        self.highlighter.highlight(line, pos)
163    }
164
165    fn highlight_char(&self, line: &str, pos: usize) -> bool {
166        self.highlighter.highlight_char(line, pos)
167    }
168
169    fn highlight_hint<'h>(&self, hint: &'h str) -> Cow<'h, str> {
170        Owned(hint.black().bold().to_string())
171    }
172}
173
174impl<'a, S> Validator for ExecHelper<'a, S> {
175    fn validate(
176        &self,
177        ctx: &mut validate::ValidationContext,
178    ) -> rustyline::Result<validate::ValidationResult> {
179        self.validator.validate(ctx)
180    }
181
182    fn validate_while_typing(&self) -> bool {
183        self.validator.validate_while_typing()
184    }
185}
186
187// TODO: We should probably rename this. The 'Exec' prefix is meaningless I think.
188struct ExecValidator {
189    brackets: MatchingBracketValidator,
190}
191
192impl ExecValidator {
193    fn new() -> ExecValidator {
194        ExecValidator {
195            brackets: MatchingBracketValidator::new(),
196        }
197    }
198
199    fn is_currently_in_quote(&self, input: &str) -> bool {
200        let input_iter = input.chars();
201
202        let mut escaped = false;
203        let mut currently_in_quote = false;
204        let mut current_quote = ' ';
205
206        // Walk through the string. There are three distinct classes of possibilities:
207        // 1. We meet a quote.
208        // In this case, what we do depends on if we've seen an unmatched quote character.
209        // If we have, then this closes the quotation block, so we are _not_ in quote.
210        // If not, then that means this starts a quotation block that hasn't been closed, which
211        // would mean we _are_ in quote.
212        // If this quote is escaped, then treat it identically to case 3 and ignore it, continuing
213        // to the next character.
214        //
215        // 2. We meet a slash.
216        // This implies escaping. Everytime we see a slash, we toggle the escaped flag. This way, a
217        // single slash, '\', makes us ready to escape the next character. Two slashes, '\\', makes
218        // us treat the next character normally. Three, '\\\', makes us escape the next character.
219        // So on, so forth. The escape flag is toggled off if we meet a character that is not
220        // slash.
221        //
222        // 3. Neither of the above. Continue to the next character.
223        //
224        //
225        // NOTE: The algorithm above only considers a quotation block closed if it finds a
226        // quotation character of the _same kind_. Therefore, the string: "hello world' is _not_
227        // closed!
228        // NOTE: A quotation character of a different class is ignored as if it was escaped if it
229        // is contained between quote characters of the other class. e.g. "'" is valid, even though
230        // `'` (single-quote character) is not technically balanced..
231        for ch in input_iter {
232            if ch == '\\' {
233                escaped = !escaped;
234                continue;
235            }
236
237            let is_quote = ch == '\"' || ch == '\'';
238            if is_quote && !escaped {
239                if currently_in_quote && ch == current_quote {
240                    // This implies we just closed a quotation block.
241                    // Hence we are no longer in quotes:
242                    currently_in_quote = false;
243                    // And the current quote is back to non-quote:
244                    current_quote = ' ';
245                } else if currently_in_quote && ch != current_quote {
246                    // We found another quote character, but it doesn't match the quote we're
247                    // currently in scope for, so ignore it:
248                    continue;
249                } else {
250                    // We're not in a quote, but we found a quote character.
251                    // Therefore, we just entered a quotation block:
252                    currently_in_quote = true;
253                    current_quote = ch;
254                }
255            }
256
257            // Regardless of what happens, we just saw a character that is not a slash. So we
258            // are not escaped anymore.
259            escaped = false;
260        }
261
262        currently_in_quote
263    }
264
265    #[allow(clippy::unnecessary_wraps)]
266    fn validate_quotes(&self, cur_input: &str) -> rustyline::Result<validate::ValidationResult> {
267        if self.is_currently_in_quote(cur_input) {
268            return Ok(validate::ValidationResult::Incomplete);
269        }
270
271        Ok(validate::ValidationResult::Valid(None))
272    }
273
274    // validate_multiline effectively looks simply for a '\' at the end of the line, indicating
275    // that it is a multi-line input.
276    // Technically, one may say this is not perfectly 'correct'. Generally, we want to follow what
277    // bash does simply cause we assume that's what users are most familiar with and therefore
278    // expect from us. However, bash, in this case, will actually _not_ include the newline when
279    // you go to the next line, and also removes the slash.
280    //
281    // We don't... that's actually really bad. It makes it virtually useless.
282    // ...Which is why we remove it later (see Readline::readline()). But I'm not happy about this
283    // whatsoever, because ideally that removal should happen here, in the validator... not much we
284    // can do about it though, since rustyline doesn't make the input line mutable here. Plus, what
285    // we can do in Readline::readline() is limited (see the comment in that function on a
286    // drawback).
287    #[allow(clippy::unnecessary_wraps)]
288    fn validate_multiline(&self, cur_input: &str) -> rustyline::Result<validate::ValidationResult> {
289        if let Some('\\') = cur_input.chars().last() {
290            return Ok(validate::ValidationResult::Incomplete);
291        }
292
293        Ok(validate::ValidationResult::Valid(None))
294    }
295
296    fn merge_validation_results(
297        &self,
298        reses: Vec<validate::ValidationResult>,
299    ) -> validate::ValidationResult {
300        for res in reses.into_iter() {
301            match res {
302                validate::ValidationResult::Valid(_) => continue,
303                _ => return res,
304            };
305        }
306
307        validate::ValidationResult::Valid(None)
308    }
309}
310
311impl Validator for ExecValidator {
312    fn validate(
313        &self,
314        ctx: &mut validate::ValidationContext,
315    ) -> rustyline::Result<validate::ValidationResult> {
316        Ok(self.merge_validation_results(vec![
317            self.brackets.validate(ctx)?,
318            self.validate_quotes(ctx.input())?,
319            self.validate_multiline(ctx.input())?,
320        ]))
321    }
322
323    fn validate_while_typing(&self) -> bool {
324        self.brackets.validate_while_typing()
325    }
326}
327
328/// ExecCompleter enables command completion in the shell.
329struct ExecCompleter<'a, S> {
330    parser: Parser,
331    cmds: Rc<RefCell<CommandSet<'a, S>>>,
332    builtins: Rc<CommandSet<'a, Shell<'a, S>>>,
333}
334
335impl<'a, S> ExecCompleter<'a, S> {
336    /// Constructs a new `ExecCompleter`.
337    ///
338    /// # Arguments
339    /// `parser` - The parser to use for command completion.
340    /// `cmds` - The custom commands to complete for.
341    /// `builtins` - The builtins to complete for.
342    fn new(
343        parser: Parser,
344        cmds: Rc<RefCell<CommandSet<'a, S>>>,
345        builtins: Rc<CommandSet<'a, Shell<'a, S>>>,
346    ) -> ExecCompleter<'a, S> {
347        ExecCompleter {
348            parser,
349            cmds,
350            builtins,
351        }
352    }
353
354    /// Offers completion candidates for a line.
355    ///
356    /// Tries to mimic to some degree, the completion behavior in bash shells.
357    ///
358    /// In particular, this means that `pos` values that are not at the end of the line behave as
359    /// if the portion of the line prior to it is the entirety of the line. e.g.:
360    ///
361    /// ```bash
362    /// $ happ|iness
363    ///       ^ Assuming this is the cursor position...
364    /// $ happinessiness # Is the completion result.
365    /// ```
366    ///
367    /// # Arguments
368    /// `line` - The line to try offering completion candidates for.
369    /// `pos` - The position of the cursor on that line.
370    ///
371    /// # Returns
372    /// `rustyline::Result<(usize, Vec<Pair>)>` - A result of a position & completion results to
373    /// present.
374    fn complete(&self, line: &str, pos: usize) -> (usize, Vec<Pair>) {
375        // First, let's get the slice of the line leading up to the position, because really,
376        // that's what we actually care about when trying to determine the completion.
377        let partial = match line.get(..pos) {
378            Some(p) => p,
379            None => {
380                // This shouldn't ever happen, as I believe `pos` should always be within bounds of
381                // `line`. However, it doesn't hurt to be safe.
382                return (0, Vec::new());
383            }
384        };
385
386        // Now, try parsing what the user wants us to complete.
387        let outcome = self
388            .parser
389            .parse(partial, &self.cmds.borrow(), &self.builtins);
390
391        // If the parse was complete, then we've gone down to a leaf command, and all we have left
392        // is to try autocompletions on the arguments.
393        if outcome.complete {
394            match outcome.leaf_completion {
395                None => {
396                    return (pos, vec![]);
397                }
398                Some(completion) => match completion {
399                    Completion::Nothing => {
400                        return (pos, vec![]);
401                    }
402                    Completion::PartialArgCompletion(arg_suffixes) => {
403                        return (
404                            pos,
405                            arg_suffixes
406                                .iter()
407                                .map(|suffix_poss| Pair {
408                                    display: suffix_poss.clone(),
409                                    replacement: suffix_poss.clone(),
410                                })
411                                .collect(),
412                        );
413                    }
414                    Completion::Possibilities(possibilities) => {
415                        // Although we'd like to immediately get around to giving back completions, what's
416                        // important is that we pad it with a space delimiter in case the user tabs when their
417                        // cursor is adjacent to the argument, so we don't complete 'foo bar' to 'foo barbaz'
418                        // and instead get 'foo bar baz'.
419                        // Note how we don't do this for partial arg completions, since this are
420                        // meant to be concatenated.
421                        if !partial.ends_with(' ') {
422                            return (
423                                pos,
424                                vec![Pair {
425                                    display: String::from(" "),
426                                    replacement: String::from(" "),
427                                }],
428                            );
429                        }
430
431                        return (
432                            pos,
433                            possibilities
434                                .iter()
435                                .map(|poss| Pair {
436                                    display: poss.clone(),
437                                    replacement: poss.clone(),
438                                })
439                                .collect(),
440                        );
441                    }
442                },
443            }
444        }
445
446        // The outcome includes what the parser would have allowed to have existed in the string.
447        // Of these possibilities, some are better matches than others. Let's rank them as such by
448        // finding those that share the first of the remaining tokens (or empty string if empty).
449        let prefix = if let Some(first_token) = outcome.remaining.first() {
450            first_token
451        } else {
452            // If the remaining is empty and we have an incomplete parse, that implies that the
453            // user has thus far entered something valid but there are more subcommands to provide.
454            // If the user then tabs to get a completion, it implies that they want to add a
455            // subcommand. Before we can do that, we need a space delimiter, so that should be our
456            // provided completion if it does not yet exist!
457            //
458            // ... with one gotcha. If the line is completely empty, we obviously should not expect
459            // a space. The start of the line is itself a delimiter of sorts.
460            if partial.is_empty() {
461                ""
462            } else if !partial.ends_with(' ') {
463                // As said before, complete this as a space so that the next attempt at tab
464                // completion gives the results the user likely actually wanted to see.
465                return (
466                    pos,
467                    vec![Pair {
468                        display: String::from(" "),
469                        replacement: String::from(" "),
470                    }],
471                );
472            } else {
473                // Otherwise, the user already has the delimiter. So now we should provide any and
474                // all subsequent subcommands.
475                ""
476            }
477        };
478
479        // So now, filter out those that have that aforementioned token as a prefix. And once we
480        // have that, grab the suffix for completion.
481        let candidates = outcome.possibilities.into_iter().filter_map(|poss| {
482            if poss.starts_with(prefix) {
483                // This really should never fail to get the remaining suffix, since the condition
484                // guarantees that the prefix exists... but no harm in being safe if we can.
485                poss.get(prefix.len()..).map(|s| s.to_string())
486            } else {
487                None
488            }
489        });
490
491        // Finally, map the candidates to `Pair`'s, which is what the Completer interface wants.
492        let pairs: Vec<Pair> = candidates
493            .map(|candidate| Pair {
494                display: candidate.to_string(),
495                // Since we set our position of replacement to pos, we can just get away with
496                // returning the suffix of the candidate to append from there.
497                replacement: candidate,
498            })
499            .collect();
500
501        (pos, pairs)
502    }
503}
504
505#[cfg(test)]
506mod test {
507    use super::*;
508
509    mod completions {
510        use super::*;
511        use crate::parser::test::make_parser_cmds;
512        use crate::parser::Parser;
513
514        use pretty_assertions::assert_eq;
515
516        fn make_completer<'a>() -> ExecCompleter<'a, ()> {
517            let (cmds, builtins) = make_parser_cmds();
518
519            // Wrap these to satisfy the type checker.
520            let cmds = Rc::new(RefCell::new(cmds));
521            let builtins = Rc::new(builtins);
522
523            ExecCompleter::new(Parser::new(), cmds, builtins)
524        }
525
526        fn test_completion(
527            completer: ExecCompleter<'_, ()>,
528            line: &str,
529            pos: usize,
530            expected_pairs: Vec<Pair>,
531        ) {
532            let cmpl_res = completer.complete(line, pos);
533            let (cmpl_pos, pairs) = cmpl_res;
534            // We should always be returning a position that is the given position.
535            assert_eq!(cmpl_pos, pos, "mismatched positions");
536
537            assert_eq!(
538                pairs.len(),
539                expected_pairs.len(),
540                "mismatched number of completions"
541            );
542
543            for (p1, p2) in pairs.iter().zip(expected_pairs.iter()) {
544                assert_eq!(p1.display, p2.display, "non-matching display strings");
545                assert_eq!(
546                    p1.replacement, p2.replacement,
547                    "non-matching replacement strings"
548                );
549            }
550        }
551
552        #[test]
553        fn simple() {
554            let completer = make_completer();
555
556            let line = "grau";
557
558            test_completion(
559                completer,
560                line,
561                line.len(),
562                vec![Pair {
563                    display: "lt-c".to_string(),
564                    replacement: "lt-c".to_string(),
565                }],
566            )
567        }
568
569        #[test]
570        fn no_matches() {
571            let completer = make_completer();
572
573            let line = "idontexistlol";
574
575            test_completion(completer, line, line.len(), vec![])
576        }
577
578        #[test]
579        fn multiple_matches() {
580            let completer = make_completer();
581
582            let line = "conflict-";
583
584            test_completion(
585                completer,
586                line,
587                line.len(),
588                vec![
589                    Pair {
590                        display: "tie".to_string(),
591                        replacement: "tie".to_string(),
592                    },
593                    Pair {
594                        display: "builtin-longer-match-but-still-loses".to_string(),
595                        replacement: "builtin-longer-match-but-still-loses".to_string(),
596                    },
597                    Pair {
598                        display: "custom-wins".to_string(),
599                        replacement: "custom-wins".to_string(),
600                    },
601                ],
602            )
603        }
604
605        #[test]
606        fn nested() {
607            let completer = make_completer();
608
609            let line = "foo-c qu";
610
611            test_completion(
612                completer,
613                line,
614                line.len(),
615                vec![Pair {
616                    display: "x-c".to_string(),
617                    replacement: "x-c".to_string(),
618                }],
619            )
620        }
621
622        #[test]
623        fn already_completed() {
624            let completer = make_completer();
625
626            let line = "foo-c qux-c quux-c";
627
628            test_completion(completer, line, line.len(), vec![])
629        }
630
631        #[test]
632        fn completely_blank_for_last_command() {
633            let completer = make_completer();
634
635            let line = "foo-c qux-c ";
636
637            test_completion(
638                completer,
639                line,
640                line.len(),
641                vec![
642                    Pair {
643                        display: "quux-c".to_string(),
644                        replacement: "quux-c".to_string(),
645                    },
646                    Pair {
647                        display: "corge-c".to_string(),
648                        replacement: "corge-c".to_string(),
649                    },
650                ],
651            )
652        }
653
654        #[test]
655        fn completion_includes_a_space() {
656            let completer = make_completer();
657
658            let line = "foo-c qux-c";
659
660            test_completion(
661                completer,
662                line,
663                line.len(),
664                vec![Pair {
665                    display: " ".to_string(),
666                    replacement: " ".to_string(),
667                }],
668            )
669        }
670
671        #[test]
672        fn nothing_typed() {
673            let completer = make_completer();
674
675            let line = "";
676
677            test_completion(
678                completer,
679                line,
680                line.len(),
681                vec![
682                    Pair {
683                        display: "foo-c".to_string(),
684                        replacement: "foo-c".to_string(),
685                    },
686                    Pair {
687                        display: "grault-c".to_string(),
688                        replacement: "grault-c".to_string(),
689                    },
690                    Pair {
691                        display: "conflict-tie".to_string(),
692                        replacement: "conflict-tie".to_string(),
693                    },
694                    Pair {
695                        display: "conflict-builtin-longer-match-but-still-loses".to_string(),
696                        replacement: "conflict-builtin-longer-match-but-still-loses".to_string(),
697                    },
698                    Pair {
699                        display: "conflict-custom-wins".to_string(),
700                        replacement: "conflict-custom-wins".to_string(),
701                    },
702                ],
703            )
704        }
705
706        #[test]
707        fn non_end_pos() {
708            let completer = make_completer();
709
710            let line = "grault-c";
711
712            test_completion(
713                completer,
714                line, // 'grault-c'
715                3,    //     ^
716                vec![Pair {
717                    display: "ult-c".to_string(),
718                    replacement: "ult-c".to_string(),
719                }],
720            )
721        }
722
723        #[test]
724        fn nested_non_end_pos() {
725            let completer = make_completer();
726
727            let line = "foo-c qux-c quux-c";
728
729            test_completion(
730                completer,
731                line, // 'foo-c qux-c quux-c'
732                8,    //          ^
733                vec![Pair {
734                    display: "x-c".to_string(),
735                    replacement: "x-c".to_string(),
736                }],
737            )
738        }
739    }
740
741    mod validator {
742        use super::*;
743
744        #[derive(Debug, PartialEq)]
745        enum TestValidationResult {
746            Valid,
747            Invalid,
748            Incomplete,
749        }
750
751        impl From<validate::ValidationResult> for TestValidationResult {
752            fn from(res: validate::ValidationResult) -> Self {
753                match res {
754                    validate::ValidationResult::Valid(_) => TestValidationResult::Valid,
755                    validate::ValidationResult::Invalid(_) => TestValidationResult::Invalid,
756                    validate::ValidationResult::Incomplete => TestValidationResult::Incomplete,
757                    // ValidationResult is marked as #[non_exhaustive], so we need to do this. We
758                    // _want_ to panic, because if Rustyline adds a new case, we'd like to know about
759                    // it via a test failure.
760                    _ => panic!("unexpected ValidationResult kind"),
761                }
762            }
763        }
764
765        fn validation_res_eq(a: validate::ValidationResult, b: validate::ValidationResult) {
766            assert_eq!(TestValidationResult::from(a), TestValidationResult::from(b));
767        }
768
769        fn check_validation_res(
770            res: rustyline::Result<validate::ValidationResult>,
771            expected: validate::ValidationResult,
772        ) {
773            match res {
774                Ok(res) => {
775                    validation_res_eq(res, expected);
776                }
777                Err(err) => {
778                    panic!("did not expect an error during validation: {}", err);
779                }
780            }
781        }
782
783        fn test_validation_quotes(input: &str, expected_validity: validate::ValidationResult) {
784            let validator = ExecValidator::new();
785
786            // We have to call validate_quotes() instead of validate(), because validate() needs to
787            // take a ValidationResult, which has no public constructor.
788            let validation_res = validator.validate_quotes(input);
789
790            check_validation_res(validation_res, expected_validity);
791        }
792
793        fn test_validation_multiline(input: &str, expected_validity: validate::ValidationResult) {
794            let validator = ExecValidator::new();
795
796            let validation_res = validator.validate_multiline(input);
797
798            check_validation_res(validation_res, expected_validity);
799        }
800
801        #[test]
802        fn one_single_quote() {
803            test_validation_quotes("\'", validate::ValidationResult::Incomplete);
804        }
805
806        #[test]
807        fn one_double_quote() {
808            test_validation_quotes("\"", validate::ValidationResult::Incomplete);
809        }
810
811        #[test]
812        fn balanced_single() {
813            test_validation_quotes("\'\'", validate::ValidationResult::Valid(None));
814        }
815
816        #[test]
817        fn balanced_double() {
818            test_validation_quotes("\"\"", validate::ValidationResult::Valid(None));
819        }
820
821        #[test]
822        fn unbalanced_but_escaped_is_ok() {
823            test_validation_quotes("\\'", validate::ValidationResult::Valid(None));
824        }
825
826        #[test]
827        fn balanced_but_mismatched_quote_types_is_incomplete() {
828            test_validation_quotes("\'\"", validate::ValidationResult::Incomplete);
829        }
830
831        #[test]
832        fn nested_quotes_unbalanced_still_incomplete() {
833            test_validation_quotes("\'\"\'\"\'", validate::ValidationResult::Incomplete);
834        }
835
836        #[test]
837        fn overlapping_but_balanced_quotes_is_incomplete() {
838            test_validation_quotes("\' \" \' \"", validate::ValidationResult::Incomplete);
839        }
840
841        #[test]
842        fn multiple_escapes_valid() {
843            // This is actually the literal string `\\\'`, meaning the last quote is escaped and
844            // therefore valid.
845            test_validation_quotes("\\\\\\'", validate::ValidationResult::Valid(None));
846        }
847
848        #[test]
849        fn multiple_escapes_incomplete() {
850            // This is actually the literal string `\\\\'`, meaning the last quote is unescaped and
851            // therefore incomplete.
852            test_validation_quotes("\\\\\\\\'", validate::ValidationResult::Incomplete);
853        }
854
855        #[test]
856        fn closed_quote_block_with_unmatched_quote_inside_is_valid() {
857            test_validation_quotes("\"'\"", validate::ValidationResult::Valid(None));
858        }
859
860        #[test]
861        fn many_quoted_blocks() {
862            test_validation_quotes(
863                "\'hey how are you?\' \"im doing ok\" \'please thank me for asking\'",
864                validate::ValidationResult::Valid(None),
865            );
866
867            test_validation_quotes(
868                "'hey how are you?' \"im doing ok\" \\\\'please thank me for asking'",
869                validate::ValidationResult::Valid(None),
870            );
871        }
872
873        #[test]
874        fn slash_at_end_is_incomplete() {
875            test_validation_multiline("hello world\\", validate::ValidationResult::Incomplete);
876        }
877
878        #[test]
879        fn slash_with_trailing_character_is_complete() {
880            test_validation_multiline("hello world\\g", validate::ValidationResult::Valid(None));
881        }
882
883        #[test]
884        fn slash_with_trailing_space_is_complete() {
885            test_validation_multiline("hello world\\ ", validate::ValidationResult::Valid(None));
886        }
887
888        #[test]
889        fn no_issues_is_complete() {
890            test_validation_multiline("hello world", validate::ValidationResult::Valid(None));
891        }
892    }
893}