command expansion

pull/11164/head
Théo Daron 5 months ago
parent 21fd654cc6
commit 731985c133

@ -3,3 +3,23 @@
Command mode can be activated by pressing `:`. The built-in commands are: Command mode can be activated by pressing `:`. The built-in commands are:
{{#include ./generated/typable-cmd.md}} {{#include ./generated/typable-cmd.md}}
## 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}}"
```

@ -88,3 +88,4 @@
| `:move` | Move the current buffer and its corresponding file to a different path | | `:move` | Move the current buffer and its corresponding file to a different path |
| `:yank-diagnostic` | Yank diagnostic(s) under primary cursor to register, or clipboard by default | | `:yank-diagnostic` | Yank diagnostic(s) under primary cursor to register, or clipboard by default |
| `:read`, `:r` | Load a file into buffer | | `:read`, `:r` | Load a file into buffer |
| `:echo` | Print the processed input to the editor status |

@ -224,8 +224,18 @@ impl MappableCommand {
jobs: cx.jobs, jobs: cx.jobs,
scroll: None, 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<Cow<str>> = 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()),
} }
} }
} }

@ -2491,6 +2491,18 @@ fn read(cx: &mut compositor::Context, args: &[Cow<str>], event: PromptEvent) ->
Ok(()) Ok(())
} }
fn echo(cx: &mut compositor::Context, args: &[Cow<str>], 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] = &[ pub const TYPABLE_COMMAND_LIST: &[TypableCommand] = &[
TypableCommand { TypableCommand {
name: "quit", name: "quit",
@ -3112,6 +3124,13 @@ pub const TYPABLE_COMMAND_LIST: &[TypableCommand] = &[
fun: read, fun: read,
signature: CommandSignature::positional(&[completers::filename]), signature: CommandSignature::positional(&[completers::filename]),
}, },
TypableCommand {
name: "echo",
aliases: &[],
doc: "Print the processed input to the editor status",
fun: echo,
signature: CommandSignature::none()
},
]; ];
pub static TYPABLE_COMMAND_MAP: Lazy<HashMap<&'static str, &'static TypableCommand>> = pub static TYPABLE_COMMAND_MAP: Lazy<HashMap<&'static str, &'static TypableCommand>> =
@ -3175,6 +3194,17 @@ pub(super) fn command_mode(cx: &mut Context) {
} }
}, // completion }, // completion
move |cx: &mut compositor::Context, input: &str, event: PromptEvent| { move |cx: &mut compositor::Context, input: &str, event: PromptEvent| {
let input: Cow<str> = 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::<Vec<&str>>(); let parts = input.split_whitespace().collect::<Vec<&str>>();
if parts.is_empty() { if parts.is_empty() {
return; return;
@ -3190,7 +3220,7 @@ pub(super) fn command_mode(cx: &mut Context) {
// Handle typable commands // Handle typable commands
if let Some(cmd) = typed::TYPABLE_COMMAND_MAP.get(parts[0]) { 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(); let args = shellwords.words();
if let Err(e) = (cmd.fun)(cx, &args[1..], event) { if let Err(e) = (cmd.fun)(cx, &args[1..], event) {

@ -3,6 +3,7 @@ use helix_term::application::Application;
use super::*; use super::*;
mod movement; mod movement;
mod variable_expansion;
mod write; mod write;
#[tokio::test(flavor = "multi_thread")] #[tokio::test(flavor = "multi_thread")]

@ -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("<esc>:echo %{filename}<ret>"),
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("<esc>:echo %{basename}<ret>"),
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("<esc>:echo %{dirname}<ret>"),
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("<esc>:echo %{filename}<ret>"),
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("<esc>:echo %{basename}<ret>"),
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("<esc>:echo %{dirname}<ret>"),
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<esc>%:echo %{selection}<ret>"),
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("ihelix<ret>helix<ret>helix<ret><esc>:echo %{linenumber}<ret>"),
Some(&|app| {
assert_eq!(app.editor.get_status().unwrap().0, "4");
}),
false,
)
.await?;
}
Ok(())
}

@ -1,3 +1,5 @@
mod variable_expansion;
use crate::{ use crate::{
align_view, align_view,
document::{ document::{

@ -0,0 +1,147 @@
use crate::Editor;
use std::borrow::Cow;
impl Editor {
pub fn expand_variables<'a>(&self, input: &'a str) -> anyhow::Result<Cow<'a, str>> {
let (view, doc) = current_ref!(self);
let shell = &self.config().shell;
let mut output: Option<String> = 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)),
}
}
}
Loading…
Cancel
Save