diff --git a/book/src/keymap.md b/book/src/keymap.md index 613788637..d85fb9360 100644 --- a/book/src/keymap.md +++ b/book/src/keymap.md @@ -122,6 +122,16 @@ in reverse, or searching via smartcase. | `[D` | Go to first diagnostic in document | `goto_first_diag` | | `]D` | Go to last diagnostic in document | `goto_last_diag` | +### Shell + +| Key | Description | Command | +| ------ | ----------- | ------- | +| `\|` | Pipe each selection through shell command, replacing with output | `shell_pipe` | +| `A-\|` | Pipe each selection into shell command, ignoring output | `shell_pipe_to` | +| `!` | Run shell command, inserting output before each selection | `shell_insert_output` | +| `A-!` | Run shell command, appending output after each selection | `shell_append_output` | +| `$` | Pipe each selection into shell command, removing if the command exits >0 | `shell_keep_pipe` | + ## Select / extend mode I'm still pondering whether to keep this mode or not. It changes movement diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index d21bbe42d..6437bf521 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(); diff --git a/helix-term/src/keymap.rs b/helix-term/src/keymap.rs index 492dc2929..f3e160b1a 100644 --- a/helix-term/src/keymap.rs +++ b/helix-term/src/keymap.rs @@ -508,6 +508,11 @@ impl Default for Keymaps { }, "\"" => select_register, + "|" => shell_pipe, + "A-|" => shell_pipe_to, + "!" => shell_insert_output, + "A-!" => shell_append_output, + "$" => shell_keep_pipe, "C-z" => suspend, }); let mut select = normal.clone(); diff --git a/helix-view/src/editor.rs b/helix-view/src/editor.rs index 18cb9106e..e5ff93adb 100644 --- a/helix-view/src/editor.rs +++ b/helix-view/src/editor.rs @@ -33,6 +33,8 @@ pub struct Config { pub scroll_lines: isize, /// Mouse support. Defaults to true. pub mouse: bool, + /// Shell to use for shell commands. Defaults to ["cmd", "/C"] on Windows and ["sh", "-c"] otherwise. + pub shell: Vec, /// Line number mode. pub line_number: LineNumber, /// Middle click paste support. Defaults to true @@ -55,6 +57,11 @@ impl Default for Config { scrolloff: 5, scroll_lines: 3, mouse: true, + shell: if cfg!(windows) { + vec!["cmd".to_owned(), "/C".to_owned()] + } else { + vec!["sh".to_owned(), "-c".to_owned()] + }, line_number: LineNumber::Absolute, middle_click_paste: true, }