diff --git a/book/src/generated/typable-cmd.md b/book/src/generated/typable-cmd.md index f4fcb6f6200e..ab90e122affc 100644 --- a/book/src/generated/typable-cmd.md +++ b/book/src/generated/typable-cmd.md @@ -86,3 +86,4 @@ | `:clear-register` | Clear given register. If no argument is provided, clear all registers. | | `:redraw` | Clear and re-render the whole UI | | `:move` | Move the current buffer and its corresponding file to a different path | +| `:echo` | Print the processed input to the editor status | diff --git a/book/src/usage.md b/book/src/usage.md index e01482193193..ea25c1e3243b 100644 --- a/book/src/usage.md +++ b/book/src/usage.md @@ -8,6 +8,7 @@ - [Selecting and manipulating text with textobjects](#selecting-and-manipulating-text-with-textobjects) - [Navigating using tree-sitter textobjects](#navigating-using-tree-sitter-textobjects) - [Moving the selection with syntax-aware motions](#moving-the-selection-with-syntax-aware-motions) +- [Using variables in typed commands and mapped shortcuts](#using-variables-in-typed-commands-and-mapped-shortcuts) For a full interactive introduction to Helix, refer to the @@ -203,6 +204,26 @@ sibling, the selection will move up the syntax tree and select the previous element. As a result, using `Alt-p` with a selection on `arg1` will move the selection to the "func" `identifier`. +## Using variables in typed commands and mapped shortcuts +Helix provides several variables that can be used when typing commands or creating custom shortcuts. These variables are listed below: + +| Variable | Description | +| --- | --- | +| `%{basename}` | The name and extension of the currently focused file. | +| `%{filename}` | The absolute path of the currently focused file. | +| `%{dirname}` | The absolute path of the parent directory of the currently focused file. | +| `%{cwd}` | The absolute path of the current working directory of Helix. | +| `%{linenumber}` | The line number where the primary cursor is positioned. | +| `%{selection}` | The text selected by the primary cursor. | +| `%sh{cmd}` | Executes `cmd` with the default shell and returns the command output, if any. | + +### Example +```toml +[keys.normal] +# Print blame info for the line where the main cursor is. +C-b = ":echo %sh{git blame -L %{linenumber} %{filename}}" +``` + [lang-support]: ./lang-support.md [unimpaired-keybinds]: ./keymap.md#unimpaired [tree-sitter-nav-demo]: https://user-images.githubusercontent.com/23398472/152332550-7dfff043-36a2-4aec-b8f2-77c13eb56d6f.gif diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index 53783e4e3024..1570a2d242bb 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -196,16 +196,29 @@ impl MappableCommand { pub fn execute(&self, cx: &mut Context) { match &self { Self::Typable { name, args, doc: _ } => { - let args: Vec> = args.iter().map(Cow::from).collect(); if let Some(command) = typed::TYPABLE_COMMAND_MAP.get(name.as_str()) { let mut cx = compositor::Context { editor: cx.editor, jobs: cx.jobs, scroll: None, }; - if let Err(e) = (command.fun)(&mut cx, &args[..], PromptEvent::Validate) { - cx.editor.set_error(format!("{}", e)); - } + + let args = args.join(" "); + + match cx.editor.expand_variables(&args) { + Ok(args) => { + let args = args.split_whitespace(); + let args: Vec> = args.map(Cow::Borrowed).collect(); + + if let Err(e) = (command.fun)(&mut cx, &args[..], PromptEvent::Validate) + { + cx.editor.set_error(format!("{}", e)); + } + } + Err(err) => { + cx.editor.set_error(err.to_string()); + } + }; } } Self::Static { fun, .. } => (fun)(cx), diff --git a/helix-term/src/commands/typed.rs b/helix-term/src/commands/typed.rs index ee02a7d25e4d..f9bb7a80de4e 100644 --- a/helix-term/src/commands/typed.rs +++ b/helix-term/src/commands/typed.rs @@ -2471,6 +2471,18 @@ fn move_buffer( Ok(()) } +fn echo(cx: &mut compositor::Context, args: &[Cow], event: PromptEvent) -> anyhow::Result<()> { + if event != PromptEvent::Validate { + return Ok(()); + } + + let args = args.join(" "); + + cx.editor.set_status(args); + + Ok(()) +} + pub const TYPABLE_COMMAND_LIST: &[TypableCommand] = &[ TypableCommand { name: "quit", @@ -3078,6 +3090,13 @@ pub const TYPABLE_COMMAND_LIST: &[TypableCommand] = &[ fun: move_buffer, signature: CommandSignature::positional(&[completers::filename]), }, + TypableCommand { + name: "echo", + aliases: &[], + doc: "Print the processed input to the editor status", + fun: echo, + signature: CommandSignature::all(completers::variables) + }, ]; pub static TYPABLE_COMMAND_MAP: Lazy> = @@ -3141,6 +3160,18 @@ pub(super) fn command_mode(cx: &mut Context) { } }, // completion move |cx: &mut compositor::Context, input: &str, event: PromptEvent| { + let input: Cow = if event == PromptEvent::Validate { + match cx.editor.expand_variables(input) { + Ok(args) => args, + Err(e) => { + cx.editor.set_error(format!("{}", e)); + return; + } + } + } else { + Cow::Borrowed(input) + }; + let parts = input.split_whitespace().collect::>(); if parts.is_empty() { return; @@ -3156,7 +3187,7 @@ pub(super) fn command_mode(cx: &mut Context) { // Handle typable commands if let Some(cmd) = typed::TYPABLE_COMMAND_MAP.get(parts[0]) { - let shellwords = Shellwords::from(input); + let shellwords = Shellwords::from(input.as_ref()); let args = shellwords.words(); if let Err(e) = (cmd.fun)(cx, &args[1..], event) { diff --git a/helix-term/src/ui/mod.rs b/helix-term/src/ui/mod.rs index efa2473e01ed..7e7cb602bead 100644 --- a/helix-term/src/ui/mod.rs +++ b/helix-term/src/ui/mod.rs @@ -382,6 +382,13 @@ pub mod completers { }) } + pub fn variables(_: &Editor, input: &str) -> Vec { + fuzzy_match(input, helix_view::editor::VARIABLES, false) + .into_iter() + .map(|(name, _)| ((0..), name.to_owned().into())) + .collect() + } + #[derive(Copy, Clone, PartialEq, Eq)] enum FileMatch { /// Entry should be ignored diff --git a/helix-term/tests/test/commands.rs b/helix-term/tests/test/commands.rs index e52b142c6b68..10e1788f12cd 100644 --- a/helix-term/tests/test/commands.rs +++ b/helix-term/tests/test/commands.rs @@ -3,6 +3,7 @@ use helix_term::application::Application; use super::*; mod movement; +mod variable_expansion; mod write; #[tokio::test(flavor = "multi_thread")] diff --git a/helix-term/tests/test/commands/variable_expansion.rs b/helix-term/tests/test/commands/variable_expansion.rs new file mode 100644 index 000000000000..a0935c426c6e --- /dev/null +++ b/helix-term/tests/test/commands/variable_expansion.rs @@ -0,0 +1,133 @@ +use super::*; + +#[tokio::test(flavor = "multi_thread")] +async fn test_variable_expansion() -> anyhow::Result<()> { + { + let mut app = AppBuilder::new().build()?; + + test_key_sequence( + &mut app, + Some(":echo %{filename}"), + Some(&|app| { + assert_eq!( + app.editor.get_status().unwrap().0, + helix_view::document::SCRATCH_BUFFER_NAME + ); + }), + false, + ) + .await?; + + let mut app = AppBuilder::new().build()?; + + test_key_sequence( + &mut app, + Some(":echo %{basename}"), + Some(&|app| { + assert_eq!( + app.editor.get_status().unwrap().0, + helix_view::document::SCRATCH_BUFFER_NAME + ); + }), + false, + ) + .await?; + + let mut app = AppBuilder::new().build()?; + + test_key_sequence( + &mut app, + Some(":echo %{dirname}"), + Some(&|app| { + assert_eq!( + app.editor.get_status().unwrap().0, + helix_view::document::SCRATCH_BUFFER_NAME + ); + }), + false, + ) + .await?; + } + + { + let file = tempfile::NamedTempFile::new()?; + let mut app = AppBuilder::new().with_file(file.path(), None).build()?; + + test_key_sequence( + &mut app, + Some(":echo %{filename}"), + Some(&|app| { + assert_eq!( + app.editor.get_status().unwrap().0, + helix_stdx::path::canonicalize(file.path()) + .to_str() + .unwrap() + ); + }), + false, + ) + .await?; + + let mut app = AppBuilder::new().with_file(file.path(), None).build()?; + + test_key_sequence( + &mut app, + Some(":echo %{basename}"), + Some(&|app| { + assert_eq!( + app.editor.get_status().unwrap().0, + file.path().file_name().unwrap().to_str().unwrap() + ); + }), + false, + ) + .await?; + + let mut app = AppBuilder::new().with_file(file.path(), None).build()?; + + test_key_sequence( + &mut app, + Some(":echo %{dirname}"), + Some(&|app| { + assert_eq!( + app.editor.get_status().unwrap().0, + helix_stdx::path::canonicalize(file.path().parent().unwrap()) + .to_str() + .unwrap() + ); + }), + false, + ) + .await?; + } + + { + let file = tempfile::NamedTempFile::new()?; + let mut app = AppBuilder::new().with_file(file.path(), None).build()?; + test_key_sequence( + &mut app, + Some("ihelix%:echo %{selection}"), + Some(&|app| { + assert_eq!(app.editor.get_status().unwrap().0, "helix"); + }), + false, + ) + .await?; + } + + { + let file = tempfile::NamedTempFile::new()?; + let mut app = AppBuilder::new().with_file(file.path(), None).build()?; + test_key_sequence( + &mut app, + Some("ihelixhelixhelix:echo %{linenumber}"), + Some(&|app| { + assert_eq!(app.editor.get_status().unwrap().0, "4"); + }), + false, + ) + .await?; + } + + Ok(()) +} diff --git a/helix-view/src/editor.rs b/helix-view/src/editor.rs index 0ab4be8b2fa5..e614acae5dd9 100644 --- a/helix-view/src/editor.rs +++ b/helix-view/src/editor.rs @@ -1,3 +1,6 @@ +mod variable_expansion; +pub use variable_expansion::VARIABLES; + use crate::{ align_view, document::{DocumentSavedEventFuture, DocumentSavedEventResult, Mode, SavePoint}, diff --git a/helix-view/src/editor/variable_expansion.rs b/helix-view/src/editor/variable_expansion.rs new file mode 100644 index 000000000000..34fd90dd7d40 --- /dev/null +++ b/helix-view/src/editor/variable_expansion.rs @@ -0,0 +1,158 @@ +use std::borrow::Cow; + +use crate::Editor; + +pub const VARIABLES: [&str; 7] = [ + "%sh{}", + "%{basename}", + "%{filename}", + "%{dirname}", + "%{cwd}", + "%{linenumber}", + "%{selection}", +]; + +impl Editor { + pub fn expand_variables<'a>(&self, input: &'a str) -> anyhow::Result> { + let (view, doc) = current_ref!(self); + let shell = &self.config().shell; + + let mut output: Option = None; + + let mut chars = input.char_indices(); + let mut last_push_end: usize = 0; + + while let Some((index, char)) = chars.next() { + if char == '%' { + if let Some((_, char)) = chars.next() { + if char == '{' { + for (end, char) in chars.by_ref() { + if char == '}' { + if output.is_none() { + output = Some(String::with_capacity(input.len())) + } + + if let Some(o) = output.as_mut() { + o.push_str(&input[last_push_end..index]); + last_push_end = end + 1; + + let value = match &input[index + 2..end] { + "basename" => doc + .path() + .and_then(|it| { + it.file_name().and_then(|it| it.to_str()) + }) + .unwrap_or(crate::document::SCRATCH_BUFFER_NAME) + .to_owned(), + "filename" => doc + .path() + .and_then(|it| it.to_str()) + .unwrap_or(crate::document::SCRATCH_BUFFER_NAME) + .to_owned(), + "dirname" => doc + .path() + .and_then(|p| p.parent()) + .and_then(std::path::Path::to_str) + .unwrap_or(crate::document::SCRATCH_BUFFER_NAME) + .to_owned(), + "cwd" => helix_stdx::env::current_working_dir() + .to_str() + .unwrap() + .to_owned(), + "linenumber" => (doc + .selection(view.id) + .primary() + .cursor_line(doc.text().slice(..)) + + 1) + .to_string(), + "selection" => doc + .selection(view.id) + .primary() + .fragment(doc.text().slice(..)) + .to_string(), + _ => anyhow::bail!("Unknown variable"), + }; + + o.push_str(value.trim()); + + break; + } + } + } + } else if char == 's' { + if let (Some((_, 'h')), Some((_, '{'))) = (chars.next(), chars.next()) { + let mut right_bracket_remaining = 1; + for (end, char) in chars.by_ref() { + if char == '}' { + right_bracket_remaining -= 1; + + if right_bracket_remaining == 0 { + if output.is_none() { + output = Some(String::with_capacity(input.len())) + } + + if let Some(o) = output.as_mut() { + let body = + self.expand_variables(&input[index + 4..end])?; + + let output = tokio::task::block_in_place(move || { + helix_lsp::block_on(async move { + let mut command = + tokio::process::Command::new(&shell[0]); + command.args(&shell[1..]).arg(&body[..]); + + let output = + command.output().await.map_err(|_| { + anyhow::anyhow!( + "Shell command failed: {body}" + ) + })?; + + if output.status.success() { + String::from_utf8(output.stdout).map_err( + |_| { + anyhow::anyhow!( + "Process did not output valid UTF-8" + ) + }, + ) + } else if output.stderr.is_empty() { + Err(anyhow::anyhow!( + "Shell command failed: {body}" + )) + } else { + let stderr = + String::from_utf8_lossy(&output.stderr); + + Err(anyhow::anyhow!("{stderr}")) + } + }) + }); + o.push_str(&input[last_push_end..index]); + last_push_end = end + 1; + + o.push_str(output?.trim()); + + break; + } + } + } else if char == '{' { + right_bracket_remaining += 1; + } + } + } + } + } + } + } + + if let Some(o) = output.as_mut() { + o.push_str(&input[last_push_end..]); + } + + match output { + Some(o) => Ok(Cow::Owned(o)), + None => Ok(Cow::Borrowed(input)), + } + } +}