diff --git a/Cargo.lock b/Cargo.lock index 461da76..345beb2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -39,6 +39,29 @@ dependencies = [ "memchr", ] +[[package]] +name = "alacritty_terminal" +version = "0.24.2-dev" +source = "git+https://github.com/39555/alacritty?branch=spawn-for-testing#8bae9dd121058faf75665f73da8e7d5b8bab0a16" +dependencies = [ + "base64", + "bitflags 2.6.0", + "home", + "libc", + "log", + "miow", + "parking_lot", + "piper", + "polling", + "regex-automata", + "rustix-openpty", + "serde", + "signal-hook", + "unicode-width", + "vte 0.13.0", + "windows-sys 0.52.0", +] + [[package]] name = "allocator-api2" version = "0.2.18" @@ -195,6 +218,12 @@ dependencies = [ "syn 2.0.85", ] +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + [[package]] name = "autocfg" version = "1.4.0" @@ -216,6 +245,12 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bit-set" version = "0.8.0" @@ -327,6 +362,7 @@ dependencies = [ name = "brush-shell" version = "0.2.11" dependencies = [ + "alacritty_terminal", "anyhow", "assert_cmd", "assert_fs", @@ -582,6 +618,15 @@ dependencies = [ "thiserror", ] +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "conpty" version = "0.5.1" @@ -730,6 +775,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a81dae078cea95a014a339291cec439d2f232ebe854a9d672b796c6afafa9b7" +[[package]] +name = "cursor-icon" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96a6ac251f4a2aca6b3f91340350eab87ae57c3f127ffeb585e92bd336717991" + [[package]] name = "darling" version = "0.20.10" @@ -1151,6 +1202,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "home" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3d1354bf6b7235cb4a0576c2619fd4ed18183f689b12b006a0ee7329eeff9a5" +dependencies = [ + "windows-sys 0.52.0", +] + [[package]] name = "homedir" version = "0.3.4" @@ -1452,6 +1512,15 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "miow" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "359f76430b20a79f9e20e115b3428614e654f04fab314482fc0fda0ebd3c6044" +dependencies = [ + "windows-sys 0.48.0", +] + [[package]] name = "nix" version = "0.26.4" @@ -1648,6 +1717,17 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "piper" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "plotters" version = "0.3.7" @@ -1676,6 +1756,21 @@ dependencies = [ "plotters-backend", ] +[[package]] +name = "polling" +version = "3.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc2790cd301dec6cd3b7a025e4815cf825724a51c98dccfe6a3e55f05ffb6511" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi 0.4.0", + "pin-project-lite", + "rustix", + "tracing", + "windows-sys 0.59.0", +] + [[package]] name = "powerfmt" version = "0.2.0" @@ -1947,11 +2042,23 @@ checksum = "8acb788b847c24f28525660c4d7758620a7210875711f79e7f663cc152726811" dependencies = [ "bitflags 2.6.0", "errno", + "itoa", "libc", "linux-raw-sys", "windows-sys 0.52.0", ] +[[package]] +name = "rustix-openpty" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a25c3aad9fc1424eb82c88087789a7d938e1829724f3e4043163baf0d13cfc12" +dependencies = [ + "errno", + "libc", + "rustix", +] + [[package]] name = "rustversion" version = "1.0.18" @@ -2111,7 +2218,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55ff8ef943b384c414f54aefa961dd2bd853add74ec75e7ac74cf91dba62bcfa" dependencies = [ - "vte", + "vte 0.11.1", ] [[package]] @@ -2502,6 +2609,20 @@ dependencies = [ "vte_generate_state_changes", ] +[[package]] +name = "vte" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40eb22ae96f050e0c0d6f7ce43feeae26c348fc4dea56928ca81537cfaa6188b" +dependencies = [ + "bitflags 2.6.0", + "cursor-icon", + "log", + "serde", + "utf8parse", + "vte_generate_state_changes", +] + [[package]] name = "vte_generate_state_changes" version = "0.1.2" diff --git a/brush-shell/Cargo.toml b/brush-shell/Cargo.toml index cad0fd0..c7ea7ae 100644 --- a/brush-shell/Cargo.toml +++ b/brush-shell/Cargo.toml @@ -66,6 +66,7 @@ tokio = { version = "1.41.0", features = ["rt", "rt-multi-thread", "sync"] } anyhow = "1.0.91" assert_cmd = "2.0.16" assert_fs = "1.1.2" +alacritty_terminal = { git = "https://github.com/39555/alacritty", branch = "spawn-for-testing" } colored = "2.1.0" descape = "2.0.3" diff = "0.1.13" diff --git a/brush-shell/tests/interactive_tests.rs b/brush-shell/tests/interactive_tests.rs index 6a4973f..445f5ec 100644 --- a/brush-shell/tests/interactive_tests.rs +++ b/brush-shell/tests/interactive_tests.rs @@ -4,9 +4,21 @@ #![cfg(unix)] #![allow(clippy::panic_in_result_fn)] +use std::{ + collections::HashMap, + io::{Read, Write}, + sync::{ + mpsc::{Receiver, Sender, TryRecvError}, + Arc, + }, +}; + use anyhow::Context; use expectrl::{ - process::unix::{PtyStream, UnixProcess}, + process::{ + unix::{PtyStream, UnixProcess}, + NonBlocking, + }, repl::ReplSession, stream::log::LogStream, Expect, Session, @@ -92,14 +104,17 @@ fn run_in_bg_then_fg() -> anyhow::Result<()> { #[test] fn run_pipeline_interactively() -> anyhow::Result<()> { - let mut session = start_shell_session()?; + let mut session = start_shell_session_with_alacritty()?; // Run a pipeline interactively. session.expect_prompt()?; - session.send_line("echo hello | TERM=linux less")?; + + // NOTE: `send_line` and `\n` don't work with the `reedline` backend. + session.send("echo hello | LESS= less -X\r\n")?; session .expect("hello") .context("Echoed text didn't show up")?; + session.send("h")?; session .expect("SUMMARY") @@ -120,6 +135,9 @@ fn run_pipeline_interactively() -> anyhow::Result<()> { // Helpers // +// alacritty session +type AlacrittyShellSession = + ReplSession>>; type ShellSession = ReplSession>>; // N.B. Comment out the above line and uncomment out the following line to disable logging of the // session. type ShellSession = ReplSession>; @@ -175,3 +193,175 @@ fn start_shell_session() -> anyhow::Result { Ok(session) } + +use alacritty_terminal::{event::EventListener, event_loop::Notifier}; +use alacritty_terminal::{ + event::{Event, Notify, WindowSize}, + event_loop::EventLoop, + sync::FairMutex, + term::{test::TermSize, Config}, + tty::{self, Options, Shell}, + Term, +}; + +// handle callbacks from the terminal such as ChildExit, PtyWrite etc. +#[derive(Clone)] +pub struct AlacrittyEventListener(pub Sender); + +impl EventListener for AlacrittyEventListener { + fn send_event(&self, event: Event) { + self.0.send(event).ok(); + } +} + +// alacritty will duplicate all its text here +struct AlacrittyRecorder(std::sync::mpsc::Sender>); + +impl Write for AlacrittyRecorder { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.0.send(buf.into()).unwrap(); + Ok(buf.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +struct SessionStream { + pty_tx: Notifier, + alacritty_recording_rx: Receiver>, + non_blocking: bool, + buf: Vec, +} + +impl SessionStream { + fn new(pty_tx: Notifier, alacritty_recording_rx: Receiver>) -> Self { + SessionStream { + pty_tx, + alacritty_recording_rx, + non_blocking: true, + buf: Vec::new(), + } + } +} +// write to the shell stdin through pty +impl Write for SessionStream { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.pty_tx.notify(buf.to_owned()); + Ok(buf.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +impl Read for SessionStream { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + // TODO: `non_blocking = false` is not used, maybe it does not necessary + match self.alacritty_recording_rx.try_recv() { + Ok(l) => { + self.buf.extend(l.iter()); + let len = std::cmp::min(self.buf.len(), buf.len()); + buf[0..len].clone_from_slice(&self.buf.drain(0..len).as_slice()); + Ok(len) + } + // EOF + Err(TryRecvError::Disconnected) => Ok(0), + // We are not ready yet + Err(TryRecvError::Empty) => Err(std::io::Error::from(std::io::ErrorKind::WouldBlock)), + } + } +} + +impl NonBlocking for SessionStream { + fn set_blocking(&mut self, on: bool) -> std::io::Result<()> { + self.non_blocking = on; + Ok(()) + } +} + +// TODO: maybe some data should be stored in Session process? +struct SessionProcess {} + +fn start_shell_session_with_alacritty() -> anyhow::Result { + const DEFAULT_PROMPT: &str = "brush> "; + alacritty_terminal::tty::setup_env(); + let shell_path = assert_cmd::cargo::cargo_bin("brush"); + let shell_path = String::from_utf8_lossy(shell_path.as_os_str().as_encoded_bytes()); + let shell = Some(Shell::new( + shell_path.to_string(), + [ + "--norc", + "--noprofile", + "--disable-bracketed-paste", + "--disable-color", + // "--input-backend=basic", + ] + .into_iter() + .map(|s| s.to_string()) + .collect(), + )); + + let options = Options { + working_directory: None, + shell, + hold: false, + env: [("TERM", "xterm-256color"), ("PS1", DEFAULT_PROMPT)] + .iter() + .map(|e| (e.0.to_string(), e.1.to_string())) + .collect::>(), + }; + let size = TermSize::new(80, 10); + let config = Config::default(); + + let (events_tx, events_rx) = std::sync::mpsc::channel(); + + let term = Term::new(config, &size, AlacrittyEventListener(events_tx.clone())); + let term = Arc::new(FairMutex::new(term)); + + let size = WindowSize { + num_lines: 10, + num_cols: 80, + cell_width: 1, + cell_height: 1, + }; + + let pty = tty::new(&options, size.into(), 1u64)?; + + let event_loop = EventLoop::new( + Arc::clone(&term), + AlacrittyEventListener(events_tx.clone()), + pty, + options.hold, + true, + )?; + + let pty_tx = event_loop.channel(); + let notif = Notifier(pty_tx.clone()); + let pty_tx = Notifier(pty_tx); + + let (tx, al_buf_rx) = std::sync::mpsc::channel(); + + let _io_thread = event_loop.spawn_with_pipe(Some(AlacrittyRecorder(tx))); + + std::thread::spawn(move || { + 'ev_loop: while let Ok(ev) = events_rx.recv() { + match ev { + // write terminal response, for example response to the cursor position request + // back to the pty + Event::PtyWrite(out) => { + notif.notify(out.into_bytes()); + } + Event::ChildExit(_exit_code) => break 'ev_loop, + Event::Wakeup => {} + _ => {} + } + } + }); + let p = SessionProcess {}; + let session = expectrl::session::Session::new(p, SessionStream::new(pty_tx, al_buf_rx))?; + let session = expectrl::session::log(session, std::io::stdout())?; + + let session = expectrl::repl::ReplSession::new(session, DEFAULT_PROMPT); + Ok(session) +}