diff --git a/src/process.rs b/src/process.rs index db1c2507..bbe0683b 100644 --- a/src/process.rs +++ b/src/process.rs @@ -18,7 +18,7 @@ use std::os::unix::process::CommandExt; use std::process::Command; use std::{thread, time}; -/// Start a process in a forked tty so you can interact with it the same as you would +/// Start a process in a forked tty to interact with it like you would /// within a terminal /// /// The process and pty session are killed upon dropping `PtyProcess` @@ -144,17 +144,18 @@ impl PtyProcess { Ok(fd.into()) } - /// At the drop of `PtyProcess` the running process is killed. This is blocking forever if - /// the process does not react to a normal kill. If `kill_timeout` is set the process is - /// `kill -9`ed after duration + /// At the drop of `PtyProcess` the running process is killed (blocking). + /// + /// This is blocking forever if the process does not react to a normal kill. + /// If `kill_timeout` is set the process is `kill -9`ed after duration. pub fn set_kill_timeout(&mut self, timeout_ms: Option) { self.kill_timeout = timeout_ms.map(time::Duration::from_millis); } - /// Get status of child process, non-blocking. + /// Get status of child process (non-blocking). /// - /// This method runs waitpid on the process. - /// This means: If you ran `exit()` before or `status()` this method will + /// This method runs waitpid on the process: + /// if you ran `exit()` before or `status()` this method will /// return `None` /// /// # Example @@ -176,29 +177,34 @@ impl PtyProcess { wait::waitpid(self.child_pid, Some(wait::WaitPidFlag::WNOHANG)).ok() } - /// Wait until process has exited. This is a blocking call. + /// Wait until process has exited (non-blocking). + /// /// If the process doesn't terminate this will block forever. pub fn wait(&self) -> Result { wait::waitpid(self.child_pid, None).map_err(Error::from) } - /// Regularly exit the process, this method is blocking until the process is dead + /// Regularly exit the process (blocking). + /// + /// This method is blocking until the process is dead pub fn exit(&mut self) -> Result { self.kill(signal::SIGTERM) } - /// Non-blocking variant of `kill()` (doesn't wait for process to be killed) + /// Kill the process with a specific signal (non-blocking). pub fn signal(&mut self, sig: signal::Signal) -> Result<(), Error> { signal::kill(self.child_pid, sig).map_err(Error::from) } - /// Kill the process with a specific signal. This method blocks, until the process is dead + /// Kill the process with a specific signal (blocking). + /// + /// This method blocks until the process is dead /// - /// repeatedly sends SIGTERM to the process until it died, + /// This repeatedly sends SIGTERM to the process until it died, /// the pty session is closed upon dropping `PtyMaster`, /// so we don't need to explicitly do that here. /// - /// if `kill_timeout` is set and a repeated sending of signal does not result in the process + /// If `kill_timeout` is set and a repeated sending of signal does not result in the process /// being killed, then `kill -9` is sent after the `kill_timeout` duration has elapsed. pub fn kill(&mut self, sig: signal::Signal) -> Result { let start = time::Instant::now(); diff --git a/src/reader.rs b/src/reader.rs index a6ae6e54..b49c298f 100644 --- a/src/reader.rs +++ b/src/reader.rs @@ -8,105 +8,14 @@ use std::sync::mpsc::{Receiver, channel}; use std::thread; use std::{fmt, time}; -#[derive(Debug)] -enum PipeError { - IO(io::Error), -} - -#[derive(Debug)] -#[allow(clippy::upper_case_acronyms)] -enum PipedChar { - Char(u8), - EOF, -} - -pub enum ReadUntil { - String(String), - Regex(Regex), - EOF, - NBytes(usize), - Any(Vec), -} - -impl fmt::Display for ReadUntil { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let printable = match self { - ReadUntil::String(s) if s == "\n" => "\\n (newline)".to_owned(), - ReadUntil::String(s) if s == "\r" => "\\r (carriage return)".to_owned(), - ReadUntil::String(s) => format!("\"{s}\""), - ReadUntil::Regex(r) => format!("Regex: \"{r}\""), - ReadUntil::EOF => "EOF (End of File)".to_owned(), - ReadUntil::NBytes(n) => format!("reading {n} bytes"), - ReadUntil::Any(v) => { - let mut res = Vec::new(); - for r in v { - res.push(r.to_string()); - } - res.join(", ") - } - }; - write!(f, "{printable}") - } -} - -/// find first occurrence of needle within buffer -/// -/// # Arguments: -/// -/// - buffer: the currently read buffer from a process which will still grow in the future -/// - eof: if the process already sent an EOF or a HUP -/// -/// # Return -/// -/// Tuple with match positions: -/// 1. position before match (0 in case of EOF and Nbytes) -/// 2. position after match -pub fn find(needle: &ReadUntil, buffer: &str, eof: bool) -> Option<(usize, usize)> { - match needle { - ReadUntil::String(s) => buffer.find(s).map(|pos| (pos, pos + s.len())), - ReadUntil::Regex(pattern) => pattern.find(buffer).map(|mat| (mat.start(), mat.end())), - ReadUntil::EOF => { - if eof { - Some((0, buffer.len())) - } else { - None - } - } - ReadUntil::NBytes(n) => { - if *n <= buffer.len() { - Some((0, *n)) - } else if eof && !buffer.is_empty() { - // reached almost end of buffer, return string, even though it will be - // smaller than the wished n bytes - Some((0, buffer.len())) - } else { - None - } - } - ReadUntil::Any(anys) => anys - .iter() - // Filter matching needles - .filter_map(|any| find(any, buffer, eof)) - // Return the left-most match - .min_by(|(start1, end1), (start2, end2)| { - if start1 == start2 { - end1.cmp(end2) - } else { - start1.cmp(start2) - } - }), - } -} - -/// Options for `NBReader` -/// -/// - timeout: -/// + `None`: `read_until` is blocking forever. This is probably not what you want -/// + `Some(millis)`: after millis milliseconds a timeout error is raised -/// - `strip_ansi_escape_codes`: Whether to filter out escape codes, such as colors. +/// Options for [`NBReader`] #[derive(Default)] pub struct Options { + /// `None`: `read_until` is blocking forever. This is probably not what you want + /// + /// `Some(millis)`: after millis milliseconds a timeout error is raised pub timeout_ms: Option, + /// Whether to filter out escape codes, such as colors. pub strip_ansi_escape_codes: bool, } @@ -125,10 +34,10 @@ pub struct NBReader { impl NBReader { /// Create a new reader instance /// - /// # Arguments: + /// # Arguments /// - /// - f: file like object - /// - options: see `Options` + /// - `f`: file like object + /// - `options`: see [`Options`] pub fn new(f: R, options: Options) -> NBReader { let (tx, rx) = channel(); @@ -177,7 +86,7 @@ impl NBReader { } } - /// reads all available chars from the read channel and stores them in self.buffer + /// Reads all available chars from the read channel and stores them in [`Self::buffer`] fn read_into_buffer(&mut self) -> Result<(), Error> { if self.eof { return Ok(()); @@ -199,24 +108,13 @@ impl NBReader { Ok(()) } - /// Read until needle is found (blocking!) and return tuple with: - /// 1. yet unread string until and without needle - /// 2. matched needle + /// Read until needle is found (blocking!) /// /// This methods loops (while reading from the Cursor) until the needle is found. /// - /// There are different modes: - /// - /// - `ReadUntil::String` searches for string (use '\n'.`to_string()` to search for newline). - /// Returns not yet read data in first String, and needle in second String - /// - `ReadUntil::Regex` searches for regex - /// Returns not yet read data in first String and matched regex in second String - /// - `ReadUntil::NBytes` reads maximum n bytes - /// Returns n bytes in second String, first String is left empty - /// - `ReadUntil::EOF` reads until end of file is reached - /// Returns all bytes in second String, first is left empty - /// - /// Note that when used with a tty the lines end with \r\n + /// Returns a tuple with: + /// 1. yet unread string until and without needle + /// 2. matched needle /// /// Returns error if EOF is reached before the needle could be found. /// @@ -283,8 +181,9 @@ impl NBReader { } } - /// Try to read one char from internal buffer. Returns None if - /// no char is ready, Some(char) otherwise. This is non-blocking + /// Try to read one char from internal buffer (non-blocking). + /// + /// Returns `None` if no char is ready `Some(char)` otherwise. pub fn try_read(&mut self) -> Option { // discard eventual errors, EOF will be handled in read_until correctly let _ = self.read_into_buffer(); @@ -296,6 +195,112 @@ impl NBReader { } } +/// See [`NBReader::read_until`] +/// +/// Note that when used with a tty the lines end with \r\n +pub enum ReadUntil { + /// Searches for string (use '\n'.`to_string()` to search for newline). + /// + /// Returns not yet read data in first String, and needle in second String + String(String), + /// `ReadUntil::Regex` searches for regex + /// + /// Returns not yet read data in first String and matched regex in second String + Regex(Regex), + /// `ReadUntil::NBytes` reads maximum n bytes + /// + /// Returns n bytes in second String, first String is left empty + NBytes(usize), + /// `ReadUntil::EOF` reads until end of file is reached + /// + /// Returns all bytes in second String, first is left empty + EOF, + Any(Vec), +} + +impl fmt::Display for ReadUntil { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ReadUntil::String(s) if s == "\n" => write!(f, "\\n (newline)"), + ReadUntil::String(s) if s == "\r" => write!(f, "\\r (carriage return)"), + ReadUntil::String(s) => write!(f, "\"{s}\""), + ReadUntil::Regex(r) => write!(f, "Regex: \"{r}\""), + ReadUntil::NBytes(n) => write!(f, "reading {n} bytes"), + ReadUntil::EOF => write!(f, "EOF (End of File)"), + ReadUntil::Any(v) => { + for (i, r) in v.iter().enumerate() { + if i != 0 { + write!(f, ", ")?; + } + write!(f, "{r}")?; + } + Ok(()) + } + } + } +} + +/// Find first occurrence of needle within buffer +/// +/// # Arguments: +/// +/// - `buffer`: the currently read buffer from a process which will still grow in the future +/// - `eof`: if the process already sent an EOF or a HUP +/// +/// # Return +/// +/// Tuple with match positions: +/// 1. position before match (0 in case of EOF and Nbytes) +/// 2. position after match +pub fn find(needle: &ReadUntil, buffer: &str, eof: bool) -> Option<(usize, usize)> { + match needle { + ReadUntil::String(s) => buffer.find(s).map(|pos| (pos, pos + s.len())), + ReadUntil::Regex(pattern) => pattern.find(buffer).map(|mat| (mat.start(), mat.end())), + ReadUntil::EOF => { + if eof { + Some((0, buffer.len())) + } else { + None + } + } + ReadUntil::NBytes(n) => { + if *n <= buffer.len() { + Some((0, *n)) + } else if eof && !buffer.is_empty() { + // reached almost end of buffer, return string, even though it will be + // smaller than the wished n bytes + Some((0, buffer.len())) + } else { + None + } + } + ReadUntil::Any(anys) => anys + .iter() + // Filter matching needles + .filter_map(|any| find(any, buffer, eof)) + // Return the left-most match + .min_by(|(start1, end1), (start2, end2)| { + if start1 == start2 { + end1.cmp(end2) + } else { + start1.cmp(start2) + } + }), + } +} + +#[derive(Debug)] +enum PipeError { + IO(io::Error), +} + +#[derive(Debug)] +#[allow(clippy::upper_case_acronyms)] +enum PipedChar { + Char(u8), + EOF, +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/session.rs b/src/session.rs index 07399812..8c175a2a 100644 --- a/src/session.rs +++ b/src/session.rs @@ -24,9 +24,9 @@ impl StreamSession { } } - /// sends string and a newline to process + /// Sends string and a newline to process /// - /// this is guaranteed to be flushed to the process + /// This is guaranteed to be flushed to the process /// returns number of written bytes pub fn send_line(&mut self, line: &str) -> Result { let mut len = self.send(line)?; @@ -34,8 +34,10 @@ impl StreamSession { Ok(len) } - /// Send string to process. As stdin of the process is most likely buffered, you'd - /// need to call `flush()` after `send()` to make the process actually see your input. + /// Send string to process. + /// + /// As stdin of the process is most likely buffered, + /// you'd need to call `flush()` after `send()` to make the process actually see your input. /// /// Returns number of written bytes pub fn send(&mut self, s: &str) -> Result { @@ -45,7 +47,9 @@ impl StreamSession { /// Send a control code to the running process and consume resulting output line /// (which is empty because echo is off) /// - /// E.g. `send_control('c')` sends ctrl-c. Upper/smaller case does not matter. + /// Upper/smaller case does not matter. + /// + /// E.g. `send_control('c')` sends ctrl-c. pub fn send_control(&mut self, c: char) -> Result<(), Error> { let code = match c { 'a'..='z' => c as u8 + 1 - b'a', @@ -63,13 +67,16 @@ impl StreamSession { Ok(()) } - /// Make sure all bytes written via `send()` are sent to the process + /// Make sure all bytes written via [`Self::send()`] are sent to the process pub fn flush(&mut self) -> Result<(), Error> { self.writer.flush().map_err(Error::from) } - /// Read one line (blocking!) and return line without the newline - /// (waits until \n is in the output fetches the line and removes \r at the end if present) + /// Read one line (blocking). + /// + /// Return line without the newline + /// + /// Waits until \n is in the output fetches the line and removes \r at the end if present. pub fn read_line(&mut self) -> Result { match self.exp(&ReadUntil::String('\n'.to_string())) { Ok((mut line, _)) => { @@ -82,24 +89,22 @@ impl StreamSession { } } - /// Return `Some(c)` if a char is ready in the stdout stream of the process, return `None` - /// otherwise. This is non-blocking. + /// Return `Some(c)` if a char is ready in the stdout stream of the process (non-blocking). + /// + /// Return `None` otherwise. pub fn try_read(&mut self) -> Option { self.reader.try_read() } - // wrapper around reader::read_until to give more context for errors - fn exp(&mut self, needle: &ReadUntil) -> Result<(String, String), Error> { - self.reader.read_until(needle) - } - /// Wait until we see EOF (i.e. child process has terminated) + /// /// Return all the yet unread output pub fn exp_eof(&mut self) -> Result { self.exp(&ReadUntil::EOF).map(|(_, s)| s) } /// Wait until provided regex is seen on stdout of child process. + /// /// Return a tuple: /// 1. the yet unread output /// 2. the matched regex @@ -111,6 +116,7 @@ impl StreamSession { } /// Wait until provided string is seen on stdout of child process. + /// /// Return the yet unread output (without the matched string) pub fn exp_string(&mut self, needle: &str) -> Result { self.exp(&ReadUntil::String(needle.to_owned())) @@ -118,6 +124,7 @@ impl StreamSession { } /// Wait until provided char is seen on stdout of child process. + /// /// Return the yet unread output (without the matched char) pub fn exp_char(&mut self, needle: char) -> Result { self.exp(&ReadUntil::String(needle.to_string())) @@ -149,7 +156,13 @@ impl StreamSession { pub fn exp_any(&mut self, needles: Vec) -> Result<(String, String), Error> { self.exp(&ReadUntil::Any(needles)) } + + // wrapper around reader::read_until to give more context for errors + fn exp(&mut self, needle: &ReadUntil) -> Result<(String, String), Error> { + self.reader.read_until(needle) + } } + /// Interact with a process with read/write/signals, etc. #[allow(dead_code)] pub struct PtySession { @@ -230,7 +243,7 @@ pub fn spawn(program: &str, timeout_ms: Option) -> Result) -> Result { spawn_with_options( command, @@ -241,7 +254,7 @@ pub fn spawn_command(command: Command, timeout_ms: Option) -> Result Result { #[cfg(feature = "which")] { @@ -253,26 +266,29 @@ pub fn spawn_with_options(command: Command, options: Options) -> Result>> " for python + /// The prompt, used for `wait_for_prompt`, e.g. ">>> " for python pub prompt: String, - /// the `pty_session` you prepared before (initiating the shell, maybe set a custom prompt, etc.) - /// see `spawn_bash` for an example + /// The `pty_session` you prepared before (initiating the shell, maybe set a custom prompt, etc.) + /// + /// See [`spawn_bash`] for an example pub pty_session: PtySession, - /// if set, then the `quit_command` is called when this object is dropped + /// If set, then the `quit_command` is called when this object is dropped /// you need to provide this if the shell you're testing is not killed by just sending /// SIGTERM pub quit_command: Option, - /// set this to true if the repl has echo on (i.e. sends user input to stdout) - /// although echo is set off at pty fork (see `PtyProcess::new`) a few repls still - /// seem to be able to send output. You may need to try with true first, and if - /// tests fail set this to false. + /// Set this to true if the repl has echo on (i.e. sends user input to stdout) + /// + /// Although echo is set off at pty fork (see `PtyProcess::new`) a few repls still + /// seem to be able to send output. + /// You may need to try with true first, and if tests fail set this to false. pub echo_on: bool, } @@ -319,9 +335,11 @@ impl PtyReplSession { Ok(()) } - /// send line to repl (and flush output) and then, if `echo_on=true` wait for the - /// input to appear. - /// Return: number of bytes written + /// Send line to repl (and flush output) + /// + /// If `echo_on=true` wait for the input to appear. + /// + /// Returns number of bytes written pub fn send_line(&mut self, line: &str) -> Result { let bytes_written = self.pty_session.send_line(line)?; if self.echo_on { @@ -360,25 +378,25 @@ impl Drop for PtyReplSession { /// Spawn bash in a pty session, run programs and expect output /// +/// The difference to [`spawn`] and [`spawn_command`] is: /// -/// The difference to `spawn` and `spawn_command` is: -/// -/// - `spawn_bash` starts bash with a custom rcfile which guarantees +/// - [`spawn_bash`] starts bash with a custom rcfile which guarantees /// a certain prompt -/// - the `PtyBashSession` also provides `wait_for_prompt` and `execute` +/// - Provides [`PtyReplSession::wait_for_prompt`] and [`PtyReplSession::execute`] /// -/// timeout: the duration until which `exp_*` returns a timeout error, or None -/// additionally, when dropping the bash prompt while bash is still blocked by a program +/// `timeout`: the duration until which `exp_*` returns a timeout error, or `None`. +/// Additionally, when dropping the bash prompt while bash is still blocked by a program /// (e.g. `sleep 9999`) then the timeout is used as a timeout before a `kill -9` is issued -/// at the bash command. Use a timeout whenever possible because it makes -/// debugging a lot easier (otherwise the program just hangs and you -/// don't know where) +/// at the bash command. +/// Use a timeout whenever possible because it makes debugging a lot easier +/// (otherwise the program just hangs and you don't know where) /// -/// bash is started with echo off. That means you don't need to "read back" -/// what you wrote to bash. But what you need to do is a `wait_for_prompt` -/// after a process finished. +/// Bash is started with echo off. +/// That means you don't need to "read back" what you wrote to bash. +/// But what you need to do is a `wait_for_prompt` after a process finished. /// -/// Also: if you start a program you should use `execute` and not `send_line`. +/// Also: if you start a program you should use [`PtyReplSession::execute`] and not +/// [`PtyReplSession::send_line`]. /// /// For an example see the README pub fn spawn_bash(timeout: Option) -> Result {