Repository: kkawakam/rustyline
Pull request #518 (2021-05-05 16:55:42)

Support multiline inputs in non TTY contexts
This PR add supports for multiline inputs (via the `Validator` API) for the case where stdin isn't a TTY (or is an unsupported terminal).

I have added a new example `input_multiline` to make quickly showcase how this works.

This lets us do:
```
echo -e "(\n\n)" | cargo run --example input_multiline
```

And have readline correctly read this as a single multiline input instead of 3 separate inputs (if it was called in a loop).
Even in the single line case, there was a difference between the TTY and the non-TTY versions of readline: the non-TTY version would keep the trailing newline character.

My main motivation for this change is to be able to easily test a REPL made using `rustyline` by piping my inputs in without having to mock the TTY part of it. It also make `rustyline` more consistent this way.


Patch:
diff --git a/examples/input_multiline.rs b/examples/input_multiline.rs
new file mode 100644
index 0000000..70a9cc8
--- /dev/null
+++ b/examples/input_multiline.rs
@@ -0,0 +1,29 @@
+use rustyline::validate::{
+    MatchingBracketValidator, ValidationContext, ValidationResult, Validator,
+};
+use rustyline::{Editor, Result};
+use rustyline_derive::{Completer, Helper, Highlighter, Hinter};
+
+#[derive(Completer, Helper, Highlighter, Hinter)]
+struct InputValidator {
+    brackets: MatchingBracketValidator,
+}
+
+impl Validator for InputValidator {
+    fn validate(&self, ctx: &mut ValidationContext) -> Result<ValidationResult> {
+        self.brackets.validate(ctx)
+    }
+}
+
+fn main() -> Result<()> {
+    let h = InputValidator {
+        brackets: MatchingBracketValidator::new(),
+    };
+    let mut rl = Editor::new();
+    rl.set_helper(Some(h));
+
+    let input = rl.readline("> ")?;
+    println!("Input: {}", input);
+
+    Ok(())
+}
diff --git a/src/lib.rs b/src/lib.rs
index 843a7b8..7a470aa 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -36,7 +36,7 @@ mod undo;
 pub mod validate;
 
 use std::fmt;
-use std::io::{self, Write};
+use std::io::{self, BufRead, Write};
 use std::path::Path;
 use std::result;
 use std::sync::{Arc, Mutex, RwLock};
@@ -585,12 +585,93 @@ fn readline_raw<H: Helper>(
     user_input
 }
 
-fn readline_direct() -> Result<String> {
-    let mut line = String::new();
-    if io::stdin().read_line(&mut line)? > 0 {
-        Ok(line)
-    } else {
-        Err(error::ReadlineError::Eof)
+// Helper to handle backspace characters in a direct input
+fn apply_backspace_direct(input: &str) -> String {
+    // Setup the output buffer
+    // No '\b' in the input in the common case, so set the capacity to the input length
+    let mut out = String::with_capacity(input.len());
+
+    // Keep track of the size of each grapheme from the input
+    // As many graphemes as input bytes in the common case
+    let mut grapheme_sizes: Vec<u8> = Vec::with_capacity(input.len());
+
+    for g in unicode_segmentation::UnicodeSegmentation::graphemes(input, true) {
+        match g {
+            // backspace char
+            "\u{0008}" => {
+                if let Some(n) = grapheme_sizes.pop() {
+                    // Remove the last grapheme
+                    out.truncate(out.len() - n as usize);
+                }
+            }
+            _ => {
+                out.push_str(g);
+                grapheme_sizes.push(g.len() as u8);
+            }
+        }
+    }
+
+    out
+}
+
+fn readline_direct(
+    mut reader: impl BufRead,
+    mut writer: impl Write,
+    validator: &Option<impl Validator>,
+) -> Result<String> {
+    let mut input = String::new();
+
+    loop {
+        match reader.read_line(&mut input)? {
+            0 => return Err(error::ReadlineError::Eof),
+            _ => {
+                // Remove trailing newline
+                let trailing_n = input.ends_with('\n');
+                let trailing_r;
+
+                if trailing_n {
+                    input.pop();
+                    trailing_r = input.ends_with('\r');
+                    if trailing_r {
+                        input.pop();
+                    }
+                } else {
+                    trailing_r = false;
+                }
+
+                input = apply_backspace_direct(&input);
+
+                match validator.as_ref() {
+                    None => return Ok(input),
+                    Some(v) => {
+                        let mut ctx = input.as_str();
+                        let mut ctx = validate::ValidationContext::new(&mut ctx);
+
+                        match v.validate(&mut ctx)? {
+                            validate::ValidationResult::Valid(msg) => {
+                                if let Some(msg) = msg {
+                                    writer.write_all(msg.as_bytes())?;
+                                }
+                                return Ok(input);
+                            }
+                            validate::ValidationResult::Invalid(Some(msg)) => {
+                                writer.write_all(msg.as_bytes())?;
+                            }
+                            validate::ValidationResult::Incomplete => {
+                                // Add newline and keep on taking input
+                                if trailing_r {
+                                    input.push('\r');
+                                }
+                                if trailing_n {
+                                    input.push('\n');
+                                }
+                            }
+                            _ => {}
+                        }
+                    }
+                }
+            }
+        }
     }
 }
 
@@ -699,13 +780,13 @@ impl<H: Helper> Editor<H> {
             stdout.write_all(prompt.as_bytes())?;
             stdout.flush()?;
 
-            readline_direct()
+            readline_direct(io::stdin().lock(), io::stderr(), &self.helper)
         } else if self.term.is_stdin_tty() {
             readline_raw(prompt, initial, self)
         } else {
             debug!(target: "rustyline", "stdin is not a tty");
             // Not a tty: read from file / pipe.
-            readline_direct()
+            readline_direct(io::stdin().lock(), io::stderr(), &self.helper)
         }
     }
 


Test patch:
diff --git a/src/test/mod.rs b/src/test/mod.rs
index 9b66b83..1575d08 100644
--- a/src/test/mod.rs
+++ b/src/test/mod.rs
@@ -12,7 +12,7 @@ use crate::keymap::{Cmd, InputState};
 use crate::keys::{KeyCode as K, KeyEvent, KeyEvent as E, Modifiers as M};
 use crate::tty::Sink;
 use crate::validate::Validator;
-use crate::{Context, Editor, Helper, Result};
+use crate::{apply_backspace_direct, readline_direct, Context, Editor, Helper, Result};
 
 mod common;
 mod emacs;
@@ -144,3 +144,29 @@ fn test_sync() {
     fn assert_sync<T: Sync>() {}
     assert_sync::<Editor<()>>();
 }
+
+#[test]
+fn test_apply_backspace_direct() {
+    assert_eq!(
+        &apply_backspace_direct("Hel\u{0008}\u{0008}el\u{0008}llo ☹\u{0008}☺"),
+        "Hello ☺"
+    );
+}
+
+#[test]
+fn test_readline_direct() {
+    use std::io::Cursor;
+
+    let mut write_buf = vec![];
+    let output = readline_direct(
+        Cursor::new("([)\n\u{0008}\n\n\r\n])".as_bytes()),
+        Cursor::new(&mut write_buf),
+        &Some(crate::validate::MatchingBracketValidator::new()),
+    );
+
+    assert_eq!(
+        &write_buf,
+        b"Mismatched brackets: '[' is not properly closed"
+    );
+    assert_eq!(&output.unwrap(), "([\n\n\r\n])");
+}