From b4f81d8a727c39077ad7d6e9e95105a408d34f92 Mon Sep 17 00:00:00 2001 From: reuben olinsky Date: Fri, 6 Dec 2024 10:21:50 -0800 Subject: [PATCH] test(completion): enable use of pexpect et al. with basic input backend --- .github/workflows/ci.yaml | 15 +- Cargo.lock | 1 + brush-core/src/commands.rs | 1 + brush-core/src/sys/stubs/signal.rs | 2 +- brush-interactive/Cargo.toml | 6 +- brush-interactive/src/basic/basic_shell.rs | 91 ++++-- brush-interactive/src/basic/mod.rs | 2 + brush-interactive/src/basic/raw_mode.rs | 34 +++ .../src/basic/term_line_reader.rs | 272 ++++++++++++++++++ brush-interactive/src/lib.rs | 6 + .../src/minimal/minimal_shell.rs | 106 +++++++ brush-interactive/src/minimal/mod.rs | 4 + brush-interactive/src/trace_categories.rs | 2 + brush-shell/Cargo.toml | 8 +- brush-shell/src/args.rs | 1 + brush-shell/src/main.rs | 1 + brush-shell/src/shell_factory.rs | 24 ++ brush-shell/tests/compat_tests.rs | 14 +- brush-shell/tests/interactive_tests.rs | 5 +- 19 files changed, 547 insertions(+), 48 deletions(-) create mode 100644 brush-interactive/src/basic/raw_mode.rs create mode 100644 brush-interactive/src/basic/term_line_reader.rs create mode 100644 brush-interactive/src/minimal/minimal_shell.rs create mode 100644 brush-interactive/src/minimal/mod.rs diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index f76a7b8f..a5f82a2f 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -35,6 +35,7 @@ jobs: os: "linux" arch: "x86_64" binary_name: "brush" + extra_build_args: "" # Build for aarch64/macos target on native host. - host: "macos-latest" target: "" @@ -42,6 +43,7 @@ jobs: arch: "aarch64" required_tools: "" binary_name: "brush" + extra_build_args: "" # Build for aarch64/linux target on x86_64/linux host. - host: "ubuntu-latest" target: "aarch64-unknown-linux-gnu" @@ -49,13 +51,15 @@ jobs: arch: "aarch64" required_tools: "gcc-aarch64-linux-gnu" binary_name: "brush" - # Build for WASI-0.1 target on x86_64/linux host. + extra_build_args: "" + # Build for WASI-0.2 target on x86_64/linux host. - host: "ubuntu-latest" - target: "wasm32-wasip1" - os: "wasi-0.1" + target: "wasm32-wasip2" + os: "wasi-0.2" arch: "wasm32" required_tools: "" binary_name: "brush.wasm" + extra_build_args: "--no-default-features --features minimal" # Build for x86_64/windows target on x86_64/linux host. - host: "ubuntu-latest" target: "x86_64-pc-windows-gnu" @@ -63,6 +67,7 @@ jobs: arch: "x86_64" required_tools: "" binary_name: "brush.exe" + extra_build_args: "" name: "Build (${{ matrix.arch }}/${{ matrix.os }})" runs-on: ${{ matrix.host }} @@ -93,11 +98,11 @@ jobs: - name: "Build (native)" if: ${{ matrix.target == '' }} - run: cargo build --release --all-targets + run: cargo build --release --all-targets ${{ matrix.extra_build_args }} - name: "Build (cross)" if: ${{ matrix.target != '' }} - run: cross build --release --target=${{ matrix.target }} + run: cross build --release --target=${{ matrix.target }} ${{ matrix.extra_build_args }} - name: "Upload binaries" uses: actions/upload-artifact@v4 diff --git a/Cargo.lock b/Cargo.lock index 741ff3da..46c22941 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-core/src/commands.rs b/brush-core/src/commands.rs index 92cb26dd..d33a9ebe 100644 --- a/brush-core/src/commands.rs +++ b/brush-core/src/commands.rs @@ -350,6 +350,7 @@ pub(crate) async fn execute( } #[allow(clippy::too_many_lines)] +#[allow(unused_variables)] pub(crate) fn execute_external_command( context: ExecutionContext<'_>, executable_path: &str, diff --git a/brush-core/src/sys/stubs/signal.rs b/brush-core/src/sys/stubs/signal.rs index 6b816cfe..91b10c0b 100644 --- a/brush-core/src/sys/stubs/signal.rs +++ b/brush-core/src/sys/stubs/signal.rs @@ -1,4 +1,4 @@ -use crate::{error, sys, traps}; +use crate::{error, sys}; pub(crate) fn continue_process(_pid: sys::process::ProcessId) -> Result<(), error::Error> { error::unimp("continue process") diff --git a/brush-interactive/Cargo.toml b/brush-interactive/Cargo.toml index f8ca392e..c9a79902 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..b18eb99b 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,32 +38,29 @@ 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 => { + if result.is_empty() { + return Ok(ReadResult::Eof); + } + break; + } + ReadResult::Interrupted => return Ok(ReadResult::Interrupted), } - - result.push_str(read_buffer.as_str()); } - if result.is_empty() { - Ok(ReadResult::Eof) - } else { - Ok(ReadResult::Input(result)) - } + Ok(ReadResult::Input(result)) } fn update_history(&mut self) -> Result<(), ShellError> { @@ -74,6 +74,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 +113,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-interactive/src/trace_categories.rs b/brush-interactive/src/trace_categories.rs index a666ef7a..b4fab328 100644 --- a/brush-interactive/src/trace_categories.rs +++ b/brush-interactive/src/trace_categories.rs @@ -1 +1,3 @@ +#![allow(dead_code)] + pub(crate) const COMPLETION: &str = "completion"; diff --git a/brush-shell/Cargo.toml b/brush-shell/Cargo.toml index 6208a698..bb634888 100644 --- a/brush-shell/Cargo.toml +++ b/brush-shell/Cargo.toml @@ -31,8 +31,9 @@ path = "tests/completion_tests.rs" [features] default = ["basic", "reedline"] -basic = [] -reedline = [] +basic = ["brush-interactive/basic"] +minimal = ["brush-interactive/minimal"] +reedline = ["brush-interactive/reedline"] [lints] workspace = true @@ -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.42.0", 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/compat_tests.rs b/brush-shell/tests/compat_tests.rs index d5ae0fc6..0d75fb5e 100644 --- a/brush-shell/tests/compat_tests.rs +++ b/brush-shell/tests/compat_tests.rs @@ -8,10 +8,8 @@ use anyhow::{Context, Result}; use assert_fs::fixture::{FileWriteStr, PathChild}; use clap::Parser; use colored::Colorize; -#[cfg(unix)] use descape::UnescapeExt; use serde::{Deserialize, Serialize}; -#[cfg(unix)] use std::os::unix::{fs::PermissionsExt, process::ExitStatusExt}; use std::{ collections::{HashMap, HashSet}, @@ -845,7 +843,6 @@ impl TestCase { test_file_path.write_str(test_file.contents.as_str())?; } - #[cfg(unix)] if test_file.executable { // chmod u+x let mut perms = test_file_path.metadata()?.permissions(); @@ -947,15 +944,7 @@ impl TestCase { let test_cmd = self.create_command_for_shell(shell_config, working_dir); let result = if self.pty { - #[cfg(unix)] - { - self.run_command_with_pty(test_cmd).await? - } - - #[cfg(not(unix))] - { - panic!("PTY tests are only supported on Unix-like systems"); - } + self.run_command_with_pty(test_cmd).await? } else { self.run_command_with_stdin(test_cmd).await? }; @@ -1019,7 +1008,6 @@ impl TestCase { } #[allow(clippy::unused_async)] - #[cfg(unix)] async fn run_command_with_pty(&self, cmd: std::process::Command) -> Result { use expectrl::Expect; 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) }