on master help sign in
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`

commite772808a5b0417e4d074eb9683d79376f83dae2d authorOmnikar <omkar.subramaniam@icloud.com> dateTue 31 Aug 2021, 05:13 (5 years ago) parentdbfd054
4 files changed+156−2
book/src/keymap.md+10 helix-term/src/commands.rs+134−2 helix-term/src/keymap.rs+5 helix-view/src/editor.rs+7
book/src/keymap.md+10
@@ -122,6 +122,16 @@ in reverse, or searching via smartcase.
122122| `[D` | Go to first diagnostic in document | `goto_first_diag` |
123123| `]D` | Go to last diagnostic in document | `goto_last_diag` |
124124
125+### Shell
126+
127+| Key | Description | Command |
128+| ------ | ----------- | ------- |
129+| `\|` | Pipe each selection through shell command, replacing with output | `shell_pipe` |
130+| `A-\|` | Pipe each selection into shell command, ignoring output | `shell_pipe_to` |
131+| `!` | Run shell command, inserting output before each selection | `shell_insert_output` |
132+| `A-!` | Run shell command, appending output after each selection | `shell_append_output` |
133+| `$` | Pipe each selection into shell command, removing if the command exits >0 | `shell_keep_pipe` |
134+
125135## Select / extend mode
126136
127137I'm still pondering whether to keep this mode or not. It changes movement
helix-term/src/commands.rs+134−2
@@ -131,7 +131,7 @@ pub struct Command {
131131}
132132
133133macro_rules! commands {
134− ( $($name:ident, $doc:literal),* ) => {
134+ ( $($name:ident, $doc:literal,)* ) => {
135135 $(
136136 #[allow(non_upper_case_globals)]
137137 pub const $name: Self = Self {
@@ -302,7 +302,12 @@ impl Command {
302302 surround_delete, "Surround delete",
303303 select_textobject_around, "Select around object",
304304 select_textobject_inner, "Select inside object",
305− suspend, "Suspend"
305+ shell_pipe, "Pipe selections through shell command",
306+ shell_pipe_to, "Pipe selections into shell command, ignoring command output",
307+ shell_insert_output, "Insert output of shell command before each selection",
308+ shell_append_output, "Append output of shell command after each selection",
309+ shell_keep_pipe, "Filter selections with shell predicate",
310+ suspend, "Suspend",
306311 );
307312}
308313
@@ -4292,6 +4297,133 @@ fn surround_delete(cx: &mut Context) {
42924297 })
42934298}
42944299
4300+#[derive(Eq, PartialEq)]
4301+enum ShellBehavior {
4302+ Replace,
4303+ Ignore,
4304+ Insert,
4305+ Append,
4306+ Filter,
4307+}
4308+
4309+fn shell_pipe(cx: &mut Context) {
4310+ shell(cx, "pipe:", ShellBehavior::Replace);
4311+}
4312+
4313+fn shell_pipe_to(cx: &mut Context) {
4314+ shell(cx, "pipe-to:", ShellBehavior::Ignore);
4315+}
4316+
4317+fn shell_insert_output(cx: &mut Context) {
4318+ shell(cx, "insert-output:", ShellBehavior::Insert);
4319+}
4320+
4321+fn shell_append_output(cx: &mut Context) {
4322+ shell(cx, "append-output:", ShellBehavior::Append);
4323+}
4324+
4325+fn shell_keep_pipe(cx: &mut Context) {
4326+ shell(cx, "keep-pipe:", ShellBehavior::Filter);
4327+}
4328+
4329+fn shell(cx: &mut Context, prompt: &str, behavior: ShellBehavior) {
4330+ use std::io::Write;
4331+ use std::process::{Command, Stdio};
4332+ if cx.editor.config.shell.is_empty() {
4333+ cx.editor.set_error("No shell set".to_owned());
4334+ return;
4335+ }
4336+ let pipe = match behavior {
4337+ ShellBehavior::Replace | ShellBehavior::Ignore | ShellBehavior::Filter => true,
4338+ ShellBehavior::Insert | ShellBehavior::Append => false,
4339+ };
4340+ let prompt = Prompt::new(
4341+ prompt.to_owned(),
4342+ Some('|'),
4343+ |_input: &str| Vec::new(),
4344+ move |cx: &mut compositor::Context, input: &str, event: PromptEvent| {
4345+ let shell = &cx.editor.config.shell;
4346+ if event == PromptEvent::Validate {
4347+ let (view, doc) = current!(cx.editor);
4348+ let selection = doc.selection(view.id);
4349+ let mut error: Option<&str> = None;
4350+ let transaction =
4351+ Transaction::change_by_selection(doc.text(), selection, |range| {
4352+ let mut process;
4353+ match Command::new(&shell[0])
4354+ .args(&shell[1..])
4355+ .arg(input)
4356+ .stdin(Stdio::piped())
4357+ .stdout(Stdio::piped())
4358+ .stderr(Stdio::piped())
4359+ .spawn()
4360+ {
4361+ Ok(p) => process = p,
4362+ Err(e) => {
4363+ log::error!("Failed to start shell: {}", e);
4364+ error = Some("Failed to start shell");
4365+ return (0, 0, None);
4366+ }
4367+ }
4368+ if pipe {
4369+ let stdin = process.stdin.as_mut().unwrap();
4370+ let fragment = range.fragment(doc.text().slice(..));
4371+ stdin.write_all(fragment.as_bytes()).unwrap();
4372+ }
4373+
4374+ let output = process.wait_with_output().unwrap();
4375+ if behavior != ShellBehavior::Filter {
4376+ if !output.status.success() {
4377+ let stderr = output.stderr;
4378+ if !stderr.is_empty() {
4379+ log::error!(
4380+ "Shell error: {}",
4381+ String::from_utf8_lossy(&stderr)
4382+ );
4383+ }
4384+ error = Some("Command failed");
4385+ return (0, 0, None);
4386+ }
4387+ let stdout = output.stdout;
4388+ let tendril;
4389+ match Tendril::try_from_byte_slice(&stdout) {
4390+ Ok(t) => tendril = t,
4391+ Err(_) => {
4392+ error = Some("Process did not output valid UTF-8");
4393+ return (0, 0, None);
4394+ }
4395+ }
4396+ let (from, to) = match behavior {
4397+ ShellBehavior::Replace => (range.from(), range.to()),
4398+ ShellBehavior::Insert => (range.from(), range.from()),
4399+ ShellBehavior::Append => (range.to(), range.to()),
4400+ _ => (range.from(), range.from()),
4401+ };
4402+ (from, to, Some(tendril))
4403+ } else {
4404+ // if the process exits successfully, keep the selection, otherwise delete it.
4405+ let keep = output.status.success();
4406+ (
4407+ range.from(),
4408+ if keep { range.from() } else { range.to() },
4409+ None,
4410+ )
4411+ }
4412+ });
4413+
4414+ if let Some(error) = error {
4415+ cx.editor.set_error(error.to_owned());
4416+ } else if behavior != ShellBehavior::Ignore {
4417+ doc.apply(&transaction, view.id);
4418+ doc.append_changes_to_history(view.id);
4419+ }
4420+ }
4421+ },
4422+ );
4423+
4424+ cx.push_layer(Box::new(prompt));
4425+}
4426+
42954427fn suspend(_cx: &mut Context) {
42964428 #[cfg(not(windows))]
42974429 signal_hook::low_level::raise(signal_hook::consts::signal::SIGTSTP).unwrap();
helix-term/src/keymap.rs+5
@@ -508,6 +508,11 @@ impl Default for Keymaps {
508508 },
509509
510510 "\"" => select_register,
511+ "|" => shell_pipe,
512+ "A-|" => shell_pipe_to,
513+ "!" => shell_insert_output,
514+ "A-!" => shell_append_output,
515+ "$" => shell_keep_pipe,
511516 "C-z" => suspend,
512517 });
513518 let mut select = normal.clone();
helix-view/src/editor.rs+7
@@ -33,6 +33,8 @@ pub struct Config {
3333 pub scroll_lines: isize,
3434 /// Mouse support. Defaults to true.
3535 pub mouse: bool,
36+ /// Shell to use for shell commands. Defaults to ["cmd", "/C"] on Windows and ["sh", "-c"] otherwise.
37+ pub shell: Vec<String>,
3638 /// Line number mode.
3739 pub line_number: LineNumber,
3840 /// Middle click paste support. Defaults to true
@@ -55,6 +57,11 @@ impl Default for Config {
5557 scrolloff: 5,
5658 scroll_lines: 3,
5759 mouse: true,
60+ shell: if cfg!(windows) {
61+ vec!["cmd".to_owned(), "/C".to_owned()]
62+ } else {
63+ vec!["sh".to_owned(), "-c".to_owned()]
64+ },
5865 line_number: LineNumber::Absolute,
5966 middle_click_paste: true,
6067 }