diff --git a/Cargo.toml b/Cargo.toml index 74a52b59..71f68424 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,6 +47,8 @@ external_printer = ["crossbeam"] sqlite = ["rusqlite/bundled", "serde_json"] sqlite-dynlib = ["rusqlite", "serde_json"] system_clipboard = ["arboard"] +execution_filter = [] # Content-based command filtering +suspend_control = [] # Terminal state management APIs [[example]] name = "cwd_aware_hinter" diff --git a/examples/execution_filter.rs b/examples/execution_filter.rs new file mode 100644 index 00000000..8914eb09 --- /dev/null +++ b/examples/execution_filter.rs @@ -0,0 +1,164 @@ +// Example of using the execution_filter feature to delegate commands +// Run with: cargo run --example execution_filter --features "execution_filter suspend_control" + +use reedline::{default_emacs_keybindings, DefaultPrompt, Emacs, Reedline, Signal}; +use std::io; + +#[cfg(feature = "execution_filter")] +use reedline::{ExecutionFilter, FilterDecision}; +#[cfg(feature = "execution_filter")] +use std::sync::Arc; + +/// Example filter that delegates interactive commands to external execution +#[cfg(feature = "execution_filter")] +#[derive(Debug)] +struct InteractiveCommandFilter { + /// Commands that need special handling (e.g., PTY allocation) + interactive_commands: Vec, +} + +#[cfg(feature = "execution_filter")] +impl InteractiveCommandFilter { + fn new() -> Self { + Self { + interactive_commands: vec![ + "vim".to_string(), + "vi".to_string(), + "nano".to_string(), + "emacs".to_string(), + "less".to_string(), + "more".to_string(), + "top".to_string(), + "htop".to_string(), + "ssh".to_string(), + "telnet".to_string(), + "python".to_string(), // Interactive Python + "ipython".to_string(), + "node".to_string(), // Interactive Node.js + "irb".to_string(), // Interactive Ruby + ], + } + } + + fn needs_special_handling(&self, command: &str) -> bool { + let cmd = command.split_whitespace().next().unwrap_or(""); + + // Check if it's an interactive command + if self.interactive_commands.iter().any(|ic| cmd == ic) { + return true; + } + + // Check for docker/podman interactive flags + if (cmd == "docker" || cmd == "podman") && command.contains("-it") { + return true; + } + + // Check if running Python/Node without arguments (interactive mode) + if (cmd == "python" || cmd == "python3" || cmd == "node") + && command.split_whitespace().count() == 1 + { + return true; + } + + false + } +} + +#[cfg(feature = "execution_filter")] +impl ExecutionFilter for InteractiveCommandFilter { + fn filter(&self, command: &str) -> FilterDecision { + if command.trim().is_empty() { + return FilterDecision::Execute(command.to_string()); + } + + if self.needs_special_handling(command) { + println!( + "Delegating '{}' to external handler (would use PTY)", + command + ); + FilterDecision::Delegate(command.to_string()) + } else { + FilterDecision::Execute(command.to_string()) + } + } +} + +fn main() -> io::Result<()> { + println!("Reedline Execution Filter Example"); + println!("=================================="); + println!("This example demonstrates automatic command delegation."); + println!("Interactive commands (vim, ssh, etc.) will be delegated."); + println!("Regular commands will execute normally."); + println!(); + + let mut line_editor = Reedline::create(); + + // Set up the execution filter + #[cfg(feature = "execution_filter")] + { + let filter = Arc::new(InteractiveCommandFilter::new()); + line_editor.set_execution_filter(filter); + println!("Execution filter installed"); + } + + #[cfg(not(feature = "execution_filter"))] + { + println!("WARNING: execution_filter feature not enabled"); + println!("Run with: --features execution_filter"); + } + + // Set up basic keybindings + let edit_mode = Box::new(Emacs::new(default_emacs_keybindings())); + line_editor = line_editor.with_edit_mode(edit_mode); + + let prompt = DefaultPrompt::default(); + + loop { + let sig = line_editor.read_line(&prompt)?; + match sig { + Signal::Success(buffer) => { + if buffer.trim() == "exit" { + println!("Goodbye!"); + break; + } + println!("Executing normally: {}", buffer); + // In a real implementation, you would execute the command here + } + #[cfg(feature = "execution_filter")] + Signal::ExecuteHostCommand(cmd) => { + println!("External handler invoked for: {}", cmd); + + // In a real implementation, you would: + // 1. Suspend the line editor + #[cfg(feature = "suspend_control")] + line_editor.suspend(); + + // 2. Execute the command with PTY + println!(" [Would execute '{}' in PTY]", cmd); + + // 3. Resume the line editor + #[cfg(feature = "suspend_control")] + { + line_editor.resume()?; + line_editor.force_repaint(&prompt)?; + } + + println!(" [Command completed]"); + } + Signal::CtrlD => { + println!("\nExiting (Ctrl+D)"); + break; + } + Signal::CtrlC => { + println!("\nInterrupted (Ctrl+C)"); + // Continue to next iteration + } + #[allow(unreachable_patterns)] + _ => { + // Handle any other signals if they exist + } + } + } + + Ok(()) +} diff --git a/src/engine.rs b/src/engine.rs index d9ba10e4..915319ec 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -170,6 +170,9 @@ pub struct Reedline { #[cfg(feature = "external_printer")] external_printer: Option>, + + #[cfg(feature = "execution_filter")] + execution_filter: Option>, } struct BufferEditor { @@ -244,6 +247,8 @@ impl Reedline { immediately_accept: false, #[cfg(feature = "external_printer")] external_printer: None, + #[cfg(feature = "execution_filter")] + execution_filter: None, } } @@ -623,6 +628,32 @@ impl Reedline { self.history.sync() } + #[cfg(feature = "execution_filter")] + /// Set the execution filter for determining when to delegate commands + pub fn set_execution_filter(&mut self, filter: std::sync::Arc) { + self.execution_filter = Some(filter); + } + + #[cfg(feature = "suspend_control")] + /// Suspend the editor (mark state for resumption) + pub fn suspend(&mut self) { + self.suspended_state = Some(self.painter.state_before_suspension()); + } + + #[cfg(feature = "suspend_control")] + /// Resume the editor (restore suspended state) + pub fn resume(&mut self) -> Result<()> { + self.suspended_state = None; + Ok(()) + } + + #[cfg(feature = "suspend_control")] + /// Force an immediate repaint + pub fn force_repaint(&mut self, prompt: &dyn Prompt) -> Result<()> { + self.repaint(prompt)?; + Ok(()) + } + /// Check if any commands have been run. /// /// When no commands have been run, calling [`Self::update_last_command_context`] @@ -698,8 +729,7 @@ impl Reedline { /// Helper implementing the logic for [`Reedline::read_line()`] to be wrapped /// in a `raw_mode` context. fn read_line_helper(&mut self, prompt: &dyn Prompt) -> Result { - self.painter - .initialize_prompt_position(self.suspended_state.as_ref())?; + self.painter.initialize_prompt_position(self.suspended_state.as_ref())?; if self.suspended_state.is_some() { // Last editor was suspended to run a ExecuteHostCommand event, // we are resuming operation now. @@ -882,8 +912,7 @@ impl Reedline { | ReedlineEvent::Submit | ReedlineEvent::SubmitOrNewline => { if let Some(string) = self.history_cursor.string_at_cursor() { - self.editor - .set_buffer(string, UndoBehavior::CreateUndoPoint); + self.editor.set_buffer(string, UndoBehavior::CreateUndoPoint); } self.input_mode = InputMode::Regular; @@ -907,9 +936,7 @@ impl Reedline { Ok(EventStatus::Handled) } ReedlineEvent::PreviousHistory | ReedlineEvent::Up | ReedlineEvent::SearchHistory => { - self.history_cursor - .back(self.history.as_ref()) - .expect("todo: error handling"); + self.history_cursor.back(self.history.as_ref()).expect("todo: error handling"); Ok(EventStatus::Handled) } ReedlineEvent::NextHistory | ReedlineEvent::Down => { @@ -918,9 +945,7 @@ impl Reedline { .expect("todo: error handling"); // Hacky way to ensure that we don't fall of into failed search going forward if self.history_cursor.string_at_cursor().is_none() { - self.history_cursor - .back(self.history.as_ref()) - .expect("todo: error handling"); + self.history_cursor.back(self.history.as_ref()).expect("todo: error handling"); } Ok(EventStatus::Handled) } @@ -1008,53 +1033,46 @@ impl Reedline { } } ReedlineEvent::MenuPrevious => { - self.active_menu() - .map_or(Ok(EventStatus::Inapplicable), |menu| { - menu.menu_event(MenuEvent::PreviousElement); - Ok(EventStatus::Handled) - }) + self.active_menu().map_or(Ok(EventStatus::Inapplicable), |menu| { + menu.menu_event(MenuEvent::PreviousElement); + Ok(EventStatus::Handled) + }) } ReedlineEvent::MenuUp => { - self.active_menu() - .map_or(Ok(EventStatus::Inapplicable), |menu| { - menu.menu_event(MenuEvent::MoveUp); - Ok(EventStatus::Handled) - }) + self.active_menu().map_or(Ok(EventStatus::Inapplicable), |menu| { + menu.menu_event(MenuEvent::MoveUp); + Ok(EventStatus::Handled) + }) } ReedlineEvent::MenuDown => { - self.active_menu() - .map_or(Ok(EventStatus::Inapplicable), |menu| { - menu.menu_event(MenuEvent::MoveDown); - Ok(EventStatus::Handled) - }) + self.active_menu().map_or(Ok(EventStatus::Inapplicable), |menu| { + menu.menu_event(MenuEvent::MoveDown); + Ok(EventStatus::Handled) + }) } ReedlineEvent::MenuLeft => { - self.active_menu() - .map_or(Ok(EventStatus::Inapplicable), |menu| { - menu.menu_event(MenuEvent::MoveLeft); - Ok(EventStatus::Handled) - }) + self.active_menu().map_or(Ok(EventStatus::Inapplicable), |menu| { + menu.menu_event(MenuEvent::MoveLeft); + Ok(EventStatus::Handled) + }) } ReedlineEvent::MenuRight => { - self.active_menu() - .map_or(Ok(EventStatus::Inapplicable), |menu| { - menu.menu_event(MenuEvent::MoveRight); - Ok(EventStatus::Handled) - }) + self.active_menu().map_or(Ok(EventStatus::Inapplicable), |menu| { + menu.menu_event(MenuEvent::MoveRight); + Ok(EventStatus::Handled) + }) } ReedlineEvent::MenuPageNext => { - self.active_menu() - .map_or(Ok(EventStatus::Inapplicable), |menu| { - menu.menu_event(MenuEvent::NextPage); - Ok(EventStatus::Handled) - }) + self.active_menu().map_or(Ok(EventStatus::Inapplicable), |menu| { + menu.menu_event(MenuEvent::NextPage); + Ok(EventStatus::Handled) + }) } ReedlineEvent::MenuPagePrevious => { - self.active_menu() - .map_or(Ok(EventStatus::Inapplicable), |menu| { - menu.menu_event(MenuEvent::PreviousPage); - Ok(EventStatus::Handled) - }) + self.active_menu().map_or(Ok(EventStatus::Inapplicable), |menu| { + menu.menu_event(MenuEvent::PreviousPage); + Ok(EventStatus::Handled) + }) } ReedlineEvent::HistoryHintComplete => { if let Some(hinter) = self.hinter.as_mut() { @@ -1299,9 +1317,7 @@ impl Reedline { } fn deactivate_menus(&mut self) { - self.menus - .iter_mut() - .for_each(|menu| menu.menu_event(MenuEvent::Deactivate)); + self.menus.iter_mut().for_each(|menu| menu.menu_event(MenuEvent::Deactivate)); } fn previous_history(&mut self) { @@ -1321,17 +1337,13 @@ impl Reedline { } if !self.history_cursor_on_excluded { - self.history_cursor - .back(self.history.as_ref()) - .expect("todo: error handling"); + self.history_cursor.back(self.history.as_ref()).expect("todo: error handling"); } self.update_buffer_from_history(); self.editor.move_to_start(false); - self.editor - .update_undo_state(UndoBehavior::HistoryNavigation); + self.editor.update_undo_state(UndoBehavior::HistoryNavigation); self.editor.move_to_line_end(false); - self.editor - .update_undo_state(UndoBehavior::HistoryNavigation); + self.editor.update_undo_state(UndoBehavior::HistoryNavigation); } fn next_history(&mut self) { @@ -1364,8 +1376,7 @@ impl Reedline { } self.update_buffer_from_history(); self.editor.move_to_end(false); - self.editor - .update_undo_state(UndoBehavior::HistoryNavigation) + self.editor.update_undo_state(UndoBehavior::HistoryNavigation) } /// Enable the search and navigation through the history from the line buffer prompt @@ -1420,9 +1431,7 @@ impl Reedline { self.get_history_session_id(), ); } - self.history_cursor - .back(self.history.as_mut()) - .expect("todo: error handling"); + self.history_cursor.back(self.history.as_mut()).expect("todo: error handling"); } EditCommand::Backspace => { let navigation = self.history_cursor.get_navigation(); @@ -1453,30 +1462,22 @@ impl Reedline { fn update_buffer_from_history(&mut self) { match self.history_cursor.get_navigation() { _ if self.history_cursor_on_excluded => self.editor.set_buffer( - self.history_excluded_item - .as_ref() - .unwrap() - .command_line - .clone(), + self.history_excluded_item.as_ref().unwrap().command_line.clone(), UndoBehavior::HistoryNavigation, ), HistoryNavigationQuery::Normal(original) => { if let Some(buffer_to_paint) = self.history_cursor.string_at_cursor() { - self.editor - .set_buffer(buffer_to_paint, UndoBehavior::HistoryNavigation); + self.editor.set_buffer(buffer_to_paint, UndoBehavior::HistoryNavigation); } else { // Hack - self.editor - .set_line_buffer(original, UndoBehavior::HistoryNavigation); + self.editor.set_line_buffer(original, UndoBehavior::HistoryNavigation); } } HistoryNavigationQuery::PrefixSearch(prefix) => { if let Some(prefix_result) = self.history_cursor.string_at_cursor() { - self.editor - .set_buffer(prefix_result, UndoBehavior::HistoryNavigation); + self.editor.set_buffer(prefix_result, UndoBehavior::HistoryNavigation); } else { - self.editor - .set_buffer(prefix, UndoBehavior::HistoryNavigation); + self.editor.set_buffer(prefix, UndoBehavior::HistoryNavigation); } } HistoryNavigationQuery::SubstringSearch(_) => todo!(), @@ -1491,8 +1492,7 @@ impl Reedline { HistoryNavigationQuery::Normal(_) ) { if let Some(string) = self.history_cursor.string_at_cursor() { - self.editor - .set_buffer(string, UndoBehavior::HistoryNavigation); + self.editor.set_buffer(string, UndoBehavior::HistoryNavigation); } } self.input_mode = InputMode::Regular; @@ -1553,78 +1553,62 @@ impl Reedline { } } - let history_result = parsed - .index - .zip(parsed.marker) - .and_then(|(index, indicator)| match parsed.action { - ParseAction::LastCommand => self - .history - .search(SearchQuery { - direction: SearchDirection::Backward, - start_time: None, - end_time: None, - start_id: None, - end_id: None, - limit: Some(1), // fetch the latest one entries - filter: SearchFilter::anything(self.get_history_session_id()), - }) - .unwrap_or_else(|_| Vec::new()) - .get(index.saturating_sub(1)) - .map(|history| { - ( - parsed.remainder.len(), - indicator.len(), - history.command_line.clone(), - ) - }), - ParseAction::BackwardSearch => self - .history - .search(SearchQuery { - direction: SearchDirection::Backward, - start_time: None, - end_time: None, - start_id: None, - end_id: None, - limit: Some(index as i64), // fetch the latest n entries - filter: SearchFilter::anything(self.get_history_session_id()), - }) - .unwrap_or_else(|_| Vec::new()) - .get(index.saturating_sub(1)) - .map(|history| { - ( - parsed.remainder.len(), - indicator.len(), - history.command_line.clone(), - ) - }), - ParseAction::BackwardPrefixSearch => { - let history_search_by_session = self + let history_result = + parsed + .index + .zip(parsed.marker) + .and_then(|(index, indicator)| match parsed.action { + ParseAction::LastCommand => self .history - .search(SearchQuery::last_with_prefix_and_cwd( - parsed.prefix.unwrap().to_string(), - self.cwd.clone().unwrap_or_else(|| { - std::env::current_dir() - .unwrap_or_default() - .to_string_lossy() - .to_string() - }), - self.get_history_session_id(), - )) + .search(SearchQuery { + direction: SearchDirection::Backward, + start_time: None, + end_time: None, + start_id: None, + end_id: None, + limit: Some(1), // fetch the latest one entries + filter: SearchFilter::anything(self.get_history_session_id()), + }) + .unwrap_or_else(|_| Vec::new()) + .get(index.saturating_sub(1)) + .map(|history| { + ( + parsed.remainder.len(), + indicator.len(), + history.command_line.clone(), + ) + }), + ParseAction::BackwardSearch => self + .history + .search(SearchQuery { + direction: SearchDirection::Backward, + start_time: None, + end_time: None, + start_id: None, + end_id: None, + limit: Some(index as i64), // fetch the latest n entries + filter: SearchFilter::anything(self.get_history_session_id()), + }) .unwrap_or_else(|_| Vec::new()) .get(index.saturating_sub(1)) .map(|history| { ( parsed.remainder.len(), - parsed_prefix.len() + parsed_marker.len(), + indicator.len(), history.command_line.clone(), ) - }); - // If we don't find any history searching by session id, then let's - // search everything, otherwise use the result from the session search - if history_search_by_session.is_none() { - self.history - .search(SearchQuery::last_with_prefix( - parsed_prefix.clone(), + }), + ParseAction::BackwardPrefixSearch => { + let history_search_by_session = self + .history + .search(SearchQuery::last_with_prefix_and_cwd( + parsed.prefix.unwrap().to_string(), + self.cwd.clone().unwrap_or_else(|| { + std::env::current_dir() + .unwrap_or_default() + .to_string_lossy() + .to_string() + }), self.get_history_session_id(), )) .unwrap_or_else(|_| Vec::new()) @@ -1635,32 +1619,49 @@ impl Reedline { parsed_prefix.len() + parsed_marker.len(), history.command_line.clone(), ) - }) - } else { - history_search_by_session + }); + // If we don't find any history searching by session id, then let's + // search everything, otherwise use the result from the session search + if history_search_by_session.is_none() { + self.history + .search(SearchQuery::last_with_prefix( + parsed_prefix.clone(), + self.get_history_session_id(), + )) + .unwrap_or_else(|_| Vec::new()) + .get(index.saturating_sub(1)) + .map(|history| { + ( + parsed.remainder.len(), + parsed_prefix.len() + parsed_marker.len(), + history.command_line.clone(), + ) + }) + } else { + history_search_by_session + } } - } - ParseAction::ForwardSearch => self - .history - .search(SearchQuery { - direction: SearchDirection::Forward, - start_time: None, - end_time: None, - start_id: None, - end_id: None, - limit: Some((index + 1) as i64), // fetch the oldest n entries - filter: SearchFilter::anything(self.get_history_session_id()), - }) - .unwrap_or_else(|_| Vec::new()) - .get(index) - .map(|history| { - ( - parsed.remainder.len(), - indicator.len(), - history.command_line.clone(), - ) - }), - ParseAction::LastToken => self + ParseAction::ForwardSearch => self + .history + .search(SearchQuery { + direction: SearchDirection::Forward, + start_time: None, + end_time: None, + start_id: None, + end_id: None, + limit: Some((index + 1) as i64), // fetch the oldest n entries + filter: SearchFilter::anything(self.get_history_session_id()), + }) + .unwrap_or_else(|_| Vec::new()) + .get(index) + .map(|history| { + ( + parsed.remainder.len(), + indicator.len(), + history.command_line.clone(), + ) + }), + ParseAction::LastToken => self .history .search(SearchQuery::last_with_search(SearchFilter::anything( self.get_history_session_id(), @@ -1670,7 +1671,7 @@ impl Reedline { //BUGBUG: This returns the wrong results with paths with spaces in them .and_then(|history| history.command_line.split_whitespace().next_back()) .map(|token| (parsed.remainder.len(), indicator.len(), token.to_string())), - }); + }); if let Some((start, size, history)) = history_result { let edits = vec![ @@ -1770,9 +1771,8 @@ impl Reedline { let cursor_position_in_buffer = self.editor.insertion_point(); let buffer_to_paint = self.editor.get_buffer(); - let mut styled_text = self - .highlighter - .highlight(buffer_to_paint, cursor_position_in_buffer); + let mut styled_text = + self.highlighter.highlight(buffer_to_paint, cursor_position_in_buffer); if let Some((from, to)) = self.editor.get_selection() { styled_text.style_range(from, to, self.visual_selection_style); } @@ -1791,10 +1791,7 @@ impl Reedline { self.history.as_ref(), self.use_ansi_coloring, &self.cwd.clone().unwrap_or_else(|| { - std::env::current_dir() - .unwrap_or_default() - .to_string_lossy() - .to_string() + std::env::current_dir().unwrap_or_default().to_string_lossy().to_string() }), ) }) @@ -1909,6 +1906,20 @@ impl Reedline { self.run_edit_commands(&[EditCommand::Clear]); self.editor.reset_undo_stack(); + #[cfg(feature = "execution_filter")] + if let Some(ref filter) = self.execution_filter { + match filter.filter(&buffer) { + crate::FilterDecision::Delegate(cmd) => { + // Mark suspended so the next read_line becomes a resume + self.suspended_state = Some(self.painter.state_before_suspension()); + return Ok(EventStatus::Exits(Signal::ExecuteHostCommand(cmd))); + } + crate::FilterDecision::Execute(line) => { + return Ok(EventStatus::Exits(Signal::Success(line))); + } + } + } + Ok(EventStatus::Exits(Signal::Success(buffer))) } } @@ -1918,3 +1929,136 @@ fn thread_safe() { fn f(_: S) {} f(Reedline::create()); } + +#[cfg(test)] +mod test { + use super::*; + + #[test] + #[cfg(feature = "execution_filter")] + fn test_execution_filter_set() { + use crate::{ExecutionFilter, FilterDecision}; + use std::sync::Arc; + + #[derive(Debug)] + struct TestFilter; + + impl ExecutionFilter for TestFilter { + fn filter(&self, _command: &str) -> FilterDecision { + FilterDecision::Execute("test".to_string()) + } + } + + let mut rl = Reedline::create(); + assert!(rl.execution_filter.is_none()); + + let filter = Arc::new(TestFilter); + rl.set_execution_filter(filter); + assert!(rl.execution_filter.is_some()); + } + + #[test] + #[cfg(feature = "execution_filter")] + fn test_execution_filter_logic() { + use crate::{ExecutionFilter, FilterDecision}; + + #[derive(Debug)] + struct TestFilter { + delegate_patterns: Vec<&'static str>, + } + + impl ExecutionFilter for TestFilter { + fn filter(&self, command: &str) -> FilterDecision { + let cmd = command.split_whitespace().next().unwrap_or(""); + if self.delegate_patterns.iter().any(|&p| cmd == p) { + FilterDecision::Delegate(command.to_string()) + } else { + FilterDecision::Execute(command.to_string()) + } + } + } + + let filter = TestFilter { + delegate_patterns: vec!["vim", "ssh"], + }; + + // Should delegate + match filter.filter("vim file.txt") { + FilterDecision::Delegate(cmd) => assert_eq!(cmd, "vim file.txt"), + _ => panic!("Expected delegation for vim command"), + } + + // Should execute normally + match filter.filter("ls -la") { + FilterDecision::Execute(cmd) => assert_eq!(cmd, "ls -la"), + _ => panic!("Expected normal execution for ls command"), + } + + // Edge case: empty command + match filter.filter("") { + FilterDecision::Execute(cmd) => assert_eq!(cmd, ""), + _ => panic!("Expected normal execution for empty command"), + } + } + + #[test] + #[cfg(feature = "suspend_control")] + fn test_suspend_and_resume() { + let mut rl = Reedline::create(); + + // Initially not suspended + assert!(rl.suspended_state.is_none()); + + // Suspend + rl.suspend(); + assert!(rl.suspended_state.is_some()); + + // Resume + let result = rl.resume(); + assert!(result.is_ok()); + assert!(rl.suspended_state.is_none()); + + // Multiple suspend/resume cycles + rl.suspend(); + assert!(rl.suspended_state.is_some()); + rl.resume().unwrap(); + assert!(rl.suspended_state.is_none()); + } + + #[test] + #[cfg(all(feature = "execution_filter", feature = "suspend_control"))] + fn test_filter_integration_with_suspend() { + use crate::{ExecutionFilter, FilterDecision}; + use std::sync::Arc; + + #[derive(Debug)] + struct TestFilter; + + impl ExecutionFilter for TestFilter { + fn filter(&self, command: &str) -> FilterDecision { + if command == "delegate" { + FilterDecision::Delegate(command.to_string()) + } else { + FilterDecision::Execute(command.to_string()) + } + } + } + + let mut rl = Reedline::create(); + let filter = Arc::new(TestFilter); + rl.set_execution_filter(filter); + + // The actual submit_buffer method is private, but we test the public APIs + assert!(rl.execution_filter.is_some()); + assert!(rl.suspended_state.is_none()); + + // Simulate what happens when a command is delegated + // (in real usage this happens inside submit_buffer) + rl.suspend(); + assert!(rl.suspended_state.is_some()); + + // After external execution, resume + rl.resume().unwrap(); + assert!(rl.suspended_state.is_none()); + } +} diff --git a/src/enums.rs b/src/enums.rs index f03ae301..228ffcdc 100644 --- a/src/enums.rs +++ b/src/enums.rs @@ -12,6 +12,9 @@ pub enum Signal { CtrlC, // Interrupt current editing /// Abort with `Ctrl+D` signalling `EOF` or abort of a whole interactive session CtrlD, // End terminal session + /// Command should be executed by external handler (when execution_filter feature is enabled) + #[cfg(feature = "execution_filter")] + ExecuteHostCommand(String), } /// Editing actions which can be mapped to key bindings. diff --git a/src/execution_filter.rs b/src/execution_filter.rs new file mode 100644 index 00000000..bba5d37d --- /dev/null +++ b/src/execution_filter.rs @@ -0,0 +1,45 @@ +/// Execution filtering for command delegation +#[cfg(feature = "execution_filter")] +use std::fmt::Debug; + +/// Decision on how to execute a command +#[cfg(feature = "execution_filter")] +#[derive(Debug)] +pub enum FilterDecision { + /// Execute the command normally in the REPL + Execute(String), + /// Delegate the command to an external handler + Delegate(String), +} + +/// Trait for filtering command execution +/// +/// This allows REPL applications to intercept commands and decide +/// whether to execute them normally or delegate to an external handler. +/// +/// # Example +/// ```no_run +/// # #[cfg(feature = "execution_filter")] +/// # { +/// use reedline::{ExecutionFilter, FilterDecision}; +/// +/// struct PtyFilter; +/// +/// impl ExecutionFilter for PtyFilter { +/// fn filter(&self, command: &str) -> FilterDecision { +/// // Check if command needs special handling +/// let cmd = command.split_whitespace().next().unwrap_or(""); +/// if matches!(cmd, "vim" | "ssh" | "nano" | "htop") { +/// FilterDecision::Delegate(command.to_string()) +/// } else { +/// FilterDecision::Execute(command.to_string()) +/// } +/// } +/// } +/// # } +/// ``` +#[cfg(feature = "execution_filter")] +pub trait ExecutionFilter: Send + Sync + Debug { + /// Decide how to execute the given command + fn filter(&self, command: &str) -> FilterDecision; +} diff --git a/src/lib.rs b/src/lib.rs index 35eccceb..4a7f55f1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -282,6 +282,11 @@ pub use menu::{ }; mod terminal_extensions; + +#[cfg(feature = "execution_filter")] +mod execution_filter; +#[cfg(feature = "execution_filter")] +pub use execution_filter::{ExecutionFilter, FilterDecision}; pub use terminal_extensions::kitty_protocol_available; mod utils;