From e772808a5b0417e4d074eb9683d79376f83dae2d Mon Sep 17 00:00:00 2001 From: Omnikar Date: Tue, 31 Aug 2021 05:13:16 -0400 Subject: Shell commands (#547) * Implement shell interaction commands * Use slice instead of iterator for shell invocation * Default to `sh` instead of `$SHELL` for shell commands * Enforce trailing comma in `commands` macro * Use `|` register for shell commands * Move shell config to `editor` and use in command * Update shell command prompts * Remove clone of shell config * Change shell function names to match prompts * Log stderr contents upon external command error * Remove `unwrap` calls on potential common errors `shell` will no longer panic if: * The user-configured shell cannot be found * The shell command does not output UTF-8 * Remove redundant `pipe` parameter * Rename `ShellBehavior::None` to `Ignore` * Display error when shell command is used and `shell = []` * Document shell commands in `keymap.md`--- helix-term/src/commands.rs | 136 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 134 insertions(+), 2 deletions(-) (limited to 'helix-term/src/commands.rs') diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index d21bbe42..6437bf52 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -131,7 +131,7 @@ pub struct Command { } macro_rules! commands { - ( $($name:ident, $doc:literal),* ) => { + ( $($name:ident, $doc:literal,)* ) => { $( #[allow(non_upper_case_globals)] pub const $name: Self = Self { @@ -302,7 +302,12 @@ impl Command { surround_delete, "Surround delete", select_textobject_around, "Select around object", select_textobject_inner, "Select inside object", - suspend, "Suspend" + shell_pipe, "Pipe selections through shell command", + shell_pipe_to, "Pipe selections into shell command, ignoring command output", + shell_insert_output, "Insert output of shell command before each selection", + shell_append_output, "Append output of shell command after each selection", + shell_keep_pipe, "Filter selections with shell predicate", + suspend, "Suspend", ); } @@ -4292,6 +4297,133 @@ fn surround_delete(cx: &mut Context) { }) } +#[derive(Eq, PartialEq)] +enum ShellBehavior { + Replace, + Ignore, + Insert, + Append, + Filter, +} + +fn shell_pipe(cx: &mut Context) { + shell(cx, "pipe:", ShellBehavior::Replace); +} + +fn shell_pipe_to(cx: &mut Context) { + shell(cx, "pipe-to:", ShellBehavior::Ignore); +} + +fn shell_insert_output(cx: &mut Context) { + shell(cx, "insert-output:", ShellBehavior::Insert); +} + +fn shell_append_output(cx: &mut Context) { + shell(cx, "append-output:", ShellBehavior::Append); +} + +fn shell_keep_pipe(cx: &mut Context) { + shell(cx, "keep-pipe:", ShellBehavior::Filter); +} + +fn shell(cx: &mut Context, prompt: &str, behavior: ShellBehavior) { + use std::io::Write; + use std::process::{Command, Stdio}; + if cx.editor.config.shell.is_empty() { + cx.editor.set_error("No shell set".to_owned()); + return; + } + let pipe = match behavior { + ShellBehavior::Replace | ShellBehavior::Ignore | ShellBehavior::Filter => true, + ShellBehavior::Insert | ShellBehavior::Append => false, + }; + let prompt = Prompt::new( + prompt.to_owned(), + Some('|'), + |_input: &str| Vec::new(), + move |cx: &mut compositor::Context, input: &str, event: PromptEvent| { + let shell = &cx.editor.config.shell; + if event == PromptEvent::Validate { + let (view, doc) = current!(cx.editor); + let selection = doc.selection(view.id); + let mut error: Option<&str> = None; + let transaction = + Transaction::change_by_selection(doc.text(), selection, |range| { + let mut process; + match Command::new(&shell[0]) + .args(&shell[1..]) + .arg(input) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + { + Ok(p) => process = p, + Err(e) => { + log::error!("Failed to start shell: {}", e); + error = Some("Failed to start shell"); + return (0, 0, None); + } + } + if pipe { + let stdin = process.stdin.as_mut().unwrap(); + let fragment = range.fragment(doc.text().slice(..)); + stdin.write_all(fragment.as_bytes()).unwrap(); + } + + let output = process.wait_with_output().unwrap(); + if behavior != ShellBehavior::Filter { + if !output.status.success() { + let stderr = output.stderr; + if !stderr.is_empty() { + log::error!( + "Shell error: {}", + String::from_utf8_lossy(&stderr) + ); + } + error = Some("Command failed"); + return (0, 0, None); + } + let stdout = output.stdout; + let tendril; + match Tendril::try_from_byte_slice(&stdout) { + Ok(t) => tendril = t, + Err(_) => { + error = Some("Process did not output valid UTF-8"); + return (0, 0, None); + } + } + let (from, to) = match behavior { + ShellBehavior::Replace => (range.from(), range.to()), + ShellBehavior::Insert => (range.from(), range.from()), + ShellBehavior::Append => (range.to(), range.to()), + _ => (range.from(), range.from()), + }; + (from, to, Some(tendril)) + } else { + // if the process exits successfully, keep the selection, otherwise delete it. + let keep = output.status.success(); + ( + range.from(), + if keep { range.from() } else { range.to() }, + None, + ) + } + }); + + if let Some(error) = error { + cx.editor.set_error(error.to_owned()); + } else if behavior != ShellBehavior::Ignore { + doc.apply(&transaction, view.id); + doc.append_changes_to_history(view.id); + } + } + }, + ); + + cx.push_layer(Box::new(prompt)); +} + fn suspend(_cx: &mut Context) { #[cfg(not(windows))] signal_hook::low_level::raise(signal_hook::consts::signal::SIGTSTP).unwrap(); -- cgit v1.2.3-70-g09d2