diff --git a/Cargo.lock b/Cargo.lock index 0a62ad6e..b3094d21 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -307,6 +307,7 @@ dependencies = [ "async-trait", "brush-core", "brush-parser", + "crossterm", "indexmap", "nu-ansi-term 0.50.1", "reedline", diff --git a/brush-interactive/Cargo.toml b/brush-interactive/Cargo.toml index c16d6314..3e540d0b 100644 --- a/brush-interactive/Cargo.toml +++ b/brush-interactive/Cargo.toml @@ -15,8 +15,9 @@ rust-version.workspace = true bench = false [features] -default = ["basic"] -basic = [] +default = [] +basic = ["dep:crossterm"] +minimal = [] reedline = ["dep:reedline", "dep:nu-ansi-term"] [lints] @@ -26,6 +27,7 @@ workspace = true async-trait = "0.1.83" brush-parser = { version = "^0.2.11", path = "../brush-parser" } brush-core = { version = "^0.2.13", path = "../brush-core" } +crossterm = { version = "0.28.1", features = ["serde"], optional = true } indexmap = "2.7.0" nu-ansi-term = { version = "0.50.1", optional = true } reedline = { version = "0.37.0", optional = true } diff --git a/brush-interactive/src/basic/basic_shell.rs b/brush-interactive/src/basic/basic_shell.rs index f86dff11..05a1562a 100644 --- a/brush-interactive/src/basic/basic_shell.rs +++ b/brush-interactive/src/basic/basic_shell.rs @@ -1,12 +1,15 @@ use std::io::{IsTerminal, Write}; use crate::{ + completion, interactive_shell::{InteractivePrompt, InteractiveShell, ReadResult}, ShellError, }; -/// Represents a minimal shell capable of taking commands from standard input -/// and reporting results to standard output and standard error streams. +use super::term_line_reader; + +/// Represents a basic shell capable of interactive usage, with primitive support +/// for completion and test-focused automation via pexpect and similar technologies. pub struct BasicShell { shell: brush_core::Shell, } @@ -35,25 +38,21 @@ impl InteractiveShell for BasicShell { } fn read_line(&mut self, prompt: InteractivePrompt) -> Result { - if self.should_display_prompt() { - print!("{}", prompt.prompt); - let _ = std::io::stdout().flush(); - } + self.display_prompt(&prompt)?; - let stdin = std::io::stdin(); let mut result = String::new(); - while result.is_empty() || !self.is_valid_input(result.as_str()) { - let mut read_buffer = String::new(); - let bytes_read = stdin - .read_line(&mut read_buffer) - .map_err(|_err| ShellError::InputError)?; - - if bytes_read == 0 { - break; + loop { + match self.read_input_line(&prompt)? { + ReadResult::Input(s) => { + result.push_str(s.as_str()); + if self.is_valid_input(result.as_str()) { + break; + } + } + ReadResult::Eof => break, + ReadResult::Interrupted => return Ok(ReadResult::Interrupted), } - - result.push_str(read_buffer.as_str()); } if result.is_empty() { @@ -74,6 +73,34 @@ impl BasicShell { std::io::stdin().is_terminal() } + fn display_prompt(&self, prompt: &InteractivePrompt) -> Result<(), ShellError> { + if self.should_display_prompt() { + eprint!("{}", prompt.prompt); + std::io::stderr().flush()?; + } + + Ok(()) + } + + fn read_input_line(&mut self, prompt: &InteractivePrompt) -> Result { + if std::io::stdin().is_terminal() { + term_line_reader::read_line(prompt.prompt.as_str(), |line, cursor| { + self.generate_completions(line, cursor) + }) + } else { + let mut input = String::new(); + let bytes_read = std::io::stdin() + .read_line(&mut input) + .map_err(|_err| ShellError::InputError)?; + + if bytes_read == 0 { + Ok(ReadResult::Eof) + } else { + Ok(ReadResult::Input(input)) + } + } + } + fn is_valid_input(&self, input: &str) -> bool { match self.shell.parse_string(input.to_owned()) { Err(brush_parser::ParseError::Tokenizing { inner, position: _ }) @@ -85,4 +112,23 @@ impl BasicShell { _ => true, } } + + fn generate_completions( + &mut self, + line: &str, + cursor: usize, + ) -> Result { + tokio::task::block_in_place(|| { + tokio::runtime::Handle::current() + .block_on(self.generate_completions_async(line, cursor)) + }) + } + + async fn generate_completions_async( + &mut self, + line: &str, + cursor: usize, + ) -> Result { + Ok(completion::complete_async(&mut self.shell, line, cursor).await) + } } diff --git a/brush-interactive/src/basic/mod.rs b/brush-interactive/src/basic/mod.rs index 2631530a..4404fa61 100644 --- a/brush-interactive/src/basic/mod.rs +++ b/brush-interactive/src/basic/mod.rs @@ -1,4 +1,6 @@ mod basic_shell; +mod raw_mode; +mod term_line_reader; #[allow(clippy::module_name_repetitions)] pub use basic_shell::BasicShell; diff --git a/brush-interactive/src/basic/raw_mode.rs b/brush-interactive/src/basic/raw_mode.rs new file mode 100644 index 00000000..a3e74363 --- /dev/null +++ b/brush-interactive/src/basic/raw_mode.rs @@ -0,0 +1,34 @@ +use crate::ShellError; + +pub(crate) struct RawModeToggle { + initial: bool, +} + +impl RawModeToggle { + pub fn new() -> Result { + let initial = crossterm::terminal::is_raw_mode_enabled()?; + Ok(Self { initial }) + } + + #[allow(clippy::unused_self)] + pub fn enable(&self) -> Result<(), ShellError> { + crossterm::terminal::enable_raw_mode()?; + Ok(()) + } + + #[allow(clippy::unused_self)] + pub fn disable(&self) -> Result<(), ShellError> { + crossterm::terminal::disable_raw_mode()?; + Ok(()) + } +} + +impl Drop for RawModeToggle { + fn drop(&mut self) { + let _ = if self.initial { + crossterm::terminal::enable_raw_mode() + } else { + crossterm::terminal::disable_raw_mode() + }; + } +} diff --git a/brush-interactive/src/basic/term_line_reader.rs b/brush-interactive/src/basic/term_line_reader.rs new file mode 100644 index 00000000..14cce83b --- /dev/null +++ b/brush-interactive/src/basic/term_line_reader.rs @@ -0,0 +1,272 @@ +// +// This module is intentionally limited, and does not have all the bells and whistles. We wan +// enough here that we can use it in the basic shell for (p)expect/pty-style testing of +// completion, and without using VT100-style escape sequences for cursor movement and display. +// + +use crossterm::ExecutableCommand; +use std::io::Write; + +use super::raw_mode; +use crate::{ReadResult, ShellError}; + +const BACKSPACE: char = 8u8 as char; + +pub(crate) fn read_line( + prompt: &str, + mut completion_handler: impl FnMut( + &str, + usize, + ) -> Result, +) -> Result { + let mut state = ReadLineState::new(prompt)?; + + loop { + state.raw_mode.enable()?; + if let crossterm::event::Event::Key(event) = crossterm::event::read()? { + if let Some(result) = state.on_key(event, &mut completion_handler)? { + return Ok(result); + } + } + } +} + +struct ReadLineState<'a> { + line: String, + cursor: usize, + prompt: &'a str, + raw_mode: raw_mode::RawModeToggle, +} + +impl<'a> ReadLineState<'a> { + fn new(prompt: &'a str) -> Result { + Ok(Self { + line: String::new(), + cursor: 0, + prompt, + raw_mode: raw_mode::RawModeToggle::new()?, + }) + } + + fn display_prompt(&self) -> Result<(), ShellError> { + self.raw_mode.disable()?; + eprint!("{}", self.prompt); + self.raw_mode.enable()?; + std::io::stderr().flush()?; + Ok(()) + } + + fn on_key( + &mut self, + event: crossterm::event::KeyEvent, + mut completion_handler: impl FnMut( + &str, + usize, + ) + -> Result, + ) -> Result, ShellError> { + match (event.modifiers, event.code) { + (_, crossterm::event::KeyCode::Enter) + | (crossterm::event::KeyModifiers::CONTROL, crossterm::event::KeyCode::Char('j')) => { + self.display_newline()?; + let line = std::mem::take(&mut self.line); + return Ok(Some(ReadResult::Input(line))); + } + ( + crossterm::event::KeyModifiers::SHIFT | crossterm::event::KeyModifiers::NONE, + crossterm::event::KeyCode::Char(c), + ) => { + self.on_char(c)?; + } + (crossterm::event::KeyModifiers::CONTROL, crossterm::event::KeyCode::Char('c')) => { + self.raw_mode.disable()?; + eprintln!("^C"); + return Ok(Some(ReadResult::Interrupted)); + } + (crossterm::event::KeyModifiers::CONTROL, crossterm::event::KeyCode::Char('d')) => { + if self.line.is_empty() { + self.raw_mode.disable()?; + eprintln!(); + return Ok(Some(ReadResult::Eof)); + } + } + (crossterm::event::KeyModifiers::CONTROL, crossterm::event::KeyCode::Char('l')) => { + self.clear_screen()?; + } + (_, crossterm::event::KeyCode::Backspace) => { + self.backspace()?; + } + (_, crossterm::event::KeyCode::Left) => { + self.move_cursor_left()?; + } + (_, crossterm::event::KeyCode::Tab) => { + let completions = completion_handler(self.line.as_str(), self.cursor)?; + self.handle_completions(&completions)?; + } + _ => (), + } + + Ok(None) + } + + fn on_char(&mut self, c: char) -> Result<(), ShellError> { + self.line.insert(self.cursor, c); + self.cursor += 1; + eprint!("{c}"); + std::io::stderr().flush()?; + + Ok(()) + } + + fn display_newline(&mut self) -> Result<(), ShellError> { + self.raw_mode.disable()?; + eprintln!(); + self.raw_mode.enable()?; + std::io::stderr().flush()?; + + Ok(()) + } + + fn clear_screen(&mut self) -> Result<(), ShellError> { + std::io::stderr() + .execute(crossterm::terminal::Clear( + crossterm::terminal::ClearType::All, + ))? + .execute(crossterm::cursor::MoveTo(0, 0))?; + + self.display_prompt()?; + eprint!("{}", self.line.as_str()); + std::io::stderr().flush()?; + Ok(()) + } + + fn backspace(&mut self) -> Result<(), ShellError> { + if self.cursor == 0 { + return Ok(()); + } + + self.cursor -= 1; + self.line.remove(self.cursor); + self.raw_mode.disable()?; + eprint!("{BACKSPACE}"); + eprint!("{} ", &self.line[self.cursor..]); + eprint!( + "{}", + repeated_char_str(BACKSPACE, self.line.len() + 1 - self.cursor) + ); + self.raw_mode.enable()?; + std::io::stderr().flush()?; + Ok(()) + } + + fn move_cursor_left(&mut self) -> Result<(), ShellError> { + self.raw_mode.disable()?; + eprint!("{BACKSPACE}"); + self.raw_mode.enable()?; + std::io::stderr().flush()?; + + if self.cursor == 0 { + return Ok(()); + } + + self.cursor -= 1; + + Ok(()) + } + + fn handle_completions( + &mut self, + completions: &brush_core::completion::Completions, + ) -> Result<(), ShellError> { + if completions.candidates.is_empty() { + // Do nothing + Ok(()) + } else if completions.candidates.len() == 1 { + self.handle_single_completion(completions) + } else { + self.handle_multiple_completions(completions) + } + } + + #[allow(clippy::unwrap_in_result)] + fn handle_single_completion( + &mut self, + completions: &brush_core::completion::Completions, + ) -> Result<(), ShellError> { + // Apply replacement directly. + let candidate = completions.candidates.iter().next().unwrap(); + if completions.insertion_index + completions.delete_count != self.cursor { + return Ok(()); + } + + let mut delete_count = completions.delete_count; + let mut redisplay_offset = completions.insertion_index; + + // Don't bother erasing and re-writing the portion of the + // completion's prefix that + // is identical to what we already had in the token-being-completed. + if delete_count > 0 + && candidate.starts_with(&self.line[redisplay_offset..redisplay_offset + delete_count]) + { + redisplay_offset += delete_count; + delete_count = 0; + } + + let mut updated_line = self.line.clone(); + updated_line.truncate(completions.insertion_index); + updated_line.push_str(candidate); + updated_line.push_str(&self.line[self.cursor..]); + self.line = updated_line; + + self.cursor = completions.insertion_index + candidate.len(); + + let move_left = repeated_char_str(BACKSPACE, delete_count); + self.raw_mode.disable()?; + eprint!("{move_left}{}", &self.line[redisplay_offset..]); + + // TODO: Remove trailing chars if completion is shorter? + eprint!( + "{}", + repeated_char_str(BACKSPACE, self.line.len() - self.cursor) + ); + + self.raw_mode.enable()?; + std::io::stderr().flush()?; + + Ok(()) + } + + fn handle_multiple_completions( + &mut self, + completions: &brush_core::completion::Completions, + ) -> Result<(), ShellError> { + // Display replacements. + self.raw_mode.disable()?; + eprintln!(); + for candidate in &completions.candidates { + eprintln!("{candidate}"); + } + self.raw_mode.enable()?; + std::io::stderr().flush()?; + + // Re-display prompt. + self.display_prompt()?; + + // Re-display line so far. + self.raw_mode.disable()?; + eprint!( + "{}{}", + self.line, + repeated_char_str(BACKSPACE, self.line.len() - self.cursor) + ); + + self.raw_mode.enable()?; + std::io::stderr().flush()?; + + Ok(()) + } +} + +fn repeated_char_str(c: char, count: usize) -> String { + (0..count).map(|_| c).collect() +} diff --git a/brush-interactive/src/lib.rs b/brush-interactive/src/lib.rs index f36b666d..3c405162 100644 --- a/brush-interactive/src/lib.rs +++ b/brush-interactive/src/lib.rs @@ -28,4 +28,10 @@ mod basic; #[cfg(feature = "basic")] pub use basic::BasicShell; +// Minimal shell +#[cfg(feature = "minimal")] +mod minimal; +#[cfg(feature = "minimal")] +pub use minimal::MinimalShell; + mod trace_categories; diff --git a/brush-interactive/src/minimal/minimal_shell.rs b/brush-interactive/src/minimal/minimal_shell.rs new file mode 100644 index 00000000..afddbf1f --- /dev/null +++ b/brush-interactive/src/minimal/minimal_shell.rs @@ -0,0 +1,106 @@ +use std::io::{IsTerminal, Write}; + +use crate::{ + interactive_shell::{InteractivePrompt, InteractiveShell, ReadResult}, + ShellError, +}; + +/// Represents a minimal shell capable of taking commands from standard input +/// and reporting results to standard output and standard error streams. +pub struct MinimalShell { + shell: brush_core::Shell, +} + +impl MinimalShell { + /// Returns a new interactive shell instance, created with the provided options. + /// + /// # Arguments + /// + /// * `options` - Options for creating the interactive shell. + pub async fn new(options: &crate::Options) -> Result { + let shell = brush_core::Shell::new(&options.shell).await?; + Ok(Self { shell }) + } +} + +impl InteractiveShell for MinimalShell { + /// Returns an immutable reference to the inner shell object. + fn shell(&self) -> impl AsRef { + self.shell.as_ref() + } + + /// Returns a mutable reference to the inner shell object. + fn shell_mut(&mut self) -> impl AsMut { + self.shell.as_mut() + } + + fn read_line(&mut self, prompt: InteractivePrompt) -> Result { + self.display_prompt(&prompt)?; + + let mut result = String::new(); + + loop { + match Self::read_input_line()? { + ReadResult::Input(s) => { + result.push_str(s.as_str()); + if self.is_valid_input(result.as_str()) { + break; + } + } + ReadResult::Eof => break, + ReadResult::Interrupted => return Ok(ReadResult::Interrupted), + } + } + + if result.is_empty() { + Ok(ReadResult::Eof) + } else { + Ok(ReadResult::Input(result)) + } + } + + fn update_history(&mut self) -> Result<(), ShellError> { + Ok(()) + } +} + +impl MinimalShell { + #[allow(clippy::unused_self)] + fn should_display_prompt(&self) -> bool { + std::io::stdin().is_terminal() + } + + fn display_prompt(&self, prompt: &InteractivePrompt) -> Result<(), ShellError> { + if self.should_display_prompt() { + eprint!("{}", prompt.prompt); + std::io::stderr().flush()?; + } + + Ok(()) + } + + fn read_input_line() -> Result { + let mut input = String::new(); + let bytes_read = std::io::stdin() + .read_line(&mut input) + .map_err(|_err| ShellError::InputError)?; + + if bytes_read == 0 { + Ok(ReadResult::Eof) + } else { + Ok(ReadResult::Input(input)) + } + } + + fn is_valid_input(&self, input: &str) -> bool { + match self.shell.parse_string(input.to_owned()) { + Err(brush_parser::ParseError::Tokenizing { inner, position: _ }) + if inner.is_incomplete() => + { + false + } + Err(brush_parser::ParseError::ParsingAtEndOfInput) => false, + _ => true, + } + } +} diff --git a/brush-interactive/src/minimal/mod.rs b/brush-interactive/src/minimal/mod.rs new file mode 100644 index 00000000..e157ed9c --- /dev/null +++ b/brush-interactive/src/minimal/mod.rs @@ -0,0 +1,4 @@ +mod minimal_shell; + +#[allow(clippy::module_name_repetitions)] +pub use minimal_shell::MinimalShell; diff --git a/brush-shell/Cargo.toml b/brush-shell/Cargo.toml index 13c3d490..505258e4 100644 --- a/brush-shell/Cargo.toml +++ b/brush-shell/Cargo.toml @@ -32,6 +32,7 @@ path = "tests/completion_tests.rs" [features] default = ["basic", "reedline"] basic = [] +minimal = [] reedline = [] [lints] @@ -52,12 +53,13 @@ human-panic = "2.0.2" [target.'cfg(not(any(windows, unix)))'.dependencies] brush-interactive = { version = "^0.2.13", path = "../brush-interactive", features = [ - "basic", + "minimal", ] } tokio = { version = "1.41.1", features = ["rt", "sync"] } [target.'cfg(any(windows, unix))'.dependencies] brush-interactive = { version = "^0.2.13", path = "../brush-interactive", features = [ + "basic", "reedline", ] } tokio = { version = "1.41.1", features = ["rt", "rt-multi-thread", "sync"] } diff --git a/brush-shell/src/args.rs b/brush-shell/src/args.rs index 0284ec70..9f215a0a 100644 --- a/brush-shell/src/args.rs +++ b/brush-shell/src/args.rs @@ -22,6 +22,7 @@ const VERSION: &str = const_format::concatcp!( pub enum InputBackend { Reedline, Basic, + Minimal, } /// Parsed command-line arguments for the brush shell. diff --git a/brush-shell/src/main.rs b/brush-shell/src/main.rs index 18fa93ee..ed533fcd 100644 --- a/brush-shell/src/main.rs +++ b/brush-shell/src/main.rs @@ -116,6 +116,7 @@ async fn run( run_impl(cli_args, args, shell_factory::ReedlineShellFactory).await } InputBackend::Basic => run_impl(cli_args, args, shell_factory::BasicShellFactory).await, + InputBackend::Minimal => run_impl(cli_args, args, shell_factory::MinimalShellFactory).await, } } diff --git a/brush-shell/src/shell_factory.rs b/brush-shell/src/shell_factory.rs index 1a770ac8..ed333ed0 100644 --- a/brush-shell/src/shell_factory.rs +++ b/brush-shell/src/shell_factory.rs @@ -97,3 +97,27 @@ impl ShellFactory for BasicShellFactory { } } } + +pub(crate) struct MinimalShellFactory; + +impl ShellFactory for MinimalShellFactory { + #[cfg(feature = "minimal")] + type ShellType = brush_interactive::MinimalShell; + #[cfg(not(feature = "minimal"))] + type ShellType = StubShell; + + #[allow(unused)] + async fn create( + &self, + options: &brush_interactive::Options, + ) -> Result { + #[cfg(feature = "minimal")] + { + brush_interactive::MinimalShell::new(options).await + } + #[cfg(not(feature = "minimal"))] + { + Err(brush_interactive::ShellError::InputBackendNotSupported) + } + } +} diff --git a/brush-shell/tests/interactive_tests.rs b/brush-shell/tests/interactive_tests.rs index 6a4973f5..9c5dc5d0 100644 --- a/brush-shell/tests/interactive_tests.rs +++ b/brush-shell/tests/interactive_tests.rs @@ -82,7 +82,7 @@ fn run_in_bg_then_fg() -> anyhow::Result<()> { // Make sure the jobs are gone. let jobs_output = session.exec_output("jobs")?; - assert!(jobs_output.trim().is_empty()); + assert_eq!(jobs_output.trim(), ""); // Exit the shell. session.exit()?; @@ -171,7 +171,8 @@ fn start_shell_session() -> anyhow::Result { // above). let session = expectrl::session::log(session, std::io::stdout())?; - let session = expectrl::repl::ReplSession::new(session, DEFAULT_PROMPT); + let mut session = expectrl::repl::ReplSession::new(session, DEFAULT_PROMPT); + session.set_echo(true); Ok(session) }