Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Command expansion v2 #11164

Open
wants to merge 15 commits into
base: master
Choose a base branch
from
Open
27 changes: 27 additions & 0 deletions book/src/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,30 @@
Command mode can be activated by pressing `:`. The built-in commands are:

{{#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}` or `%{b}` | The name and extension of the currently focused file. |
| `%{dirname}` or `%{d}` | The absolute path of the parent directory of the currently focused file. |
| `%{cwd}` | The absolute path of the current working directory of Helix. |
| `%{git_repo}` | The absolute path of the git repository helix is opened in. Fallback to `cwd` if not inside a git repository|
| `%{filename}` or `%{f}` | The absolute path of the currently focused file. |
| `%{filename:rel}` | The relative path of the file according to `cwd` (will give absolute path if the file is not a child of the current working directory) |
| `%{filename:git_rel}` | The relative path of the file according to `git_repo` (will give absolute path if the file is not a child of the git directory or the cwd) |
| `%{ext}` | The extension of the current file |
| `%{lang}` | The language of the current file |
| `%{linenumber}` | The line number where the primary cursor is positioned. |
| `%{cursorcolumn}` | The position of the primary cursor inside the current line. |
| `%{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}}"
```

1 change: 1 addition & 0 deletions book/src/generated/typable-cmd.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,3 +88,4 @@
| `:move`, `:mv` | 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 |
| `:read`, `:r` | Load a file into buffer |
| `:echo` | Print the processed input to the editor status |
201 changes: 130 additions & 71 deletions helix-core/src/shellwords.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,93 +40,120 @@ pub struct Shellwords<'a> {
impl<'a> From<&'a str> for Shellwords<'a> {
fn from(input: &'a str) -> Self {
use State::*;

let mut state = Unquoted;
let mut words = Vec::new();
let mut parts = Vec::new();
let mut escaped = String::with_capacity(input.len());

let mut inside_variable_expansion = false;
let mut nested_variable_expansion_count = 0;
let mut part_start = 0;
let mut unescaped_start = 0;
let mut end = 0;

for (i, c) in input.char_indices() {
state = match state {
OnWhitespace => match c {
'"' => {
end = i;
Dquoted
if c == '%' {
//%sh{this "should" be escaped}
if let Some(t) = input.get(i + 1..i + 3) {
if t == "sh" {
nested_variable_expansion_count += 1;
inside_variable_expansion = true;
}
'\'' => {
end = i;
Quoted
}
//%{this "should" be escaped}
if let Some(t) = input.get(i + 1..i + 2) {
if t == "{" {
nested_variable_expansion_count += 1;
inside_variable_expansion = true;
}
'\\' => {
if cfg!(unix) {
escaped.push_str(&input[unescaped_start..i]);
unescaped_start = i + 1;
UnquotedEscaped
} else {
}
}
if c == '}' {
nested_variable_expansion_count -= 1;
if nested_variable_expansion_count == 0 {
inside_variable_expansion = false;
}
}

state = if !inside_variable_expansion {
match state {
OnWhitespace => match c {
'"' => {
end = i;
Dquoted
}
'\'' => {
end = i;
Quoted
}
'\\' => {
if cfg!(unix) {
escaped.push_str(&input[unescaped_start..i]);
unescaped_start = i + 1;
UnquotedEscaped
} else {
OnWhitespace
}
}
c if c.is_ascii_whitespace() => {
end = i;
OnWhitespace
}
}
c if c.is_ascii_whitespace() => {
end = i;
OnWhitespace
}
_ => Unquoted,
},
Unquoted => match c {
'\\' => {
if cfg!(unix) {
escaped.push_str(&input[unescaped_start..i]);
unescaped_start = i + 1;
UnquotedEscaped
} else {
Unquoted
_ => Unquoted,
},
Unquoted => match c {
'\\' => {
if cfg!(unix) {
escaped.push_str(&input[unescaped_start..i]);
unescaped_start = i + 1;
UnquotedEscaped
} else {
Unquoted
}
}
}
c if c.is_ascii_whitespace() => {
end = i;
OnWhitespace
}
_ => Unquoted,
},
UnquotedEscaped => Unquoted,
Quoted => match c {
'\\' => {
if cfg!(unix) {
escaped.push_str(&input[unescaped_start..i]);
unescaped_start = i + 1;
QuoteEscaped
} else {
Quoted
c if c.is_ascii_whitespace() => {
end = i;
OnWhitespace
}
}
'\'' => {
end = i;
OnWhitespace
}
_ => Quoted,
},
QuoteEscaped => Quoted,
Dquoted => match c {
'\\' => {
if cfg!(unix) {
escaped.push_str(&input[unescaped_start..i]);
unescaped_start = i + 1;
DquoteEscaped
} else {
Dquoted
_ => Unquoted,
},
UnquotedEscaped => Unquoted,
Quoted => match c {
'\\' => {
if cfg!(unix) {
escaped.push_str(&input[unescaped_start..i]);
unescaped_start = i + 1;
QuoteEscaped
} else {
Quoted
}
}
}
'"' => {
end = i;
OnWhitespace
}
_ => Dquoted,
},
DquoteEscaped => Dquoted,
'\'' => {
end = i;
OnWhitespace
}
_ => Quoted,
},
QuoteEscaped => Quoted,
Dquoted => match c {
'\\' => {
if cfg!(unix) {
escaped.push_str(&input[unescaped_start..i]);
unescaped_start = i + 1;
DquoteEscaped
} else {
Dquoted
}
}
'"' => {
end = i;
OnWhitespace
}
_ => Dquoted,
},
DquoteEscaped => Dquoted,
}
} else {
state
};

let c_len = c.len_utf8();
Expand Down Expand Up @@ -235,6 +262,38 @@ mod test {
// TODO test is_owned and is_borrowed, once they get stabilized.
assert_eq!(expected, result);
}
#[test]
fn test_expansion() {
let input = r#"echo %{filename} %{linenumber}"#;
let shellwords = Shellwords::from(input);
let result = shellwords.words().to_vec();
let expected = vec![
Cow::from("echo"),
Cow::from("%{filename}"),
Cow::from("%{linenumber}"),
];
assert_eq!(expected, result);

let input = r#"echo %{filename} 'world' %{something to 'escape}"#;
let shellwords = Shellwords::from(input);
let result = shellwords.words().to_vec();
let expected = vec![
Cow::from("echo"),
Cow::from("%{filename}"),
Cow::from("world"),
Cow::from("%{something to 'escape}"),
];
assert_eq!(expected, result);
let input = r#"echo %sh{%sh{%{filename}}} cool"#;
let shellwords = Shellwords::from(input);
let result = shellwords.words().to_vec();
let expected = vec![
Cow::from("echo"),
Cow::from("%sh{%sh{%{filename}}}"),
Cow::from("cool"),
];
assert_eq!(expected, result);
}

#[test]
#[cfg(unix)]
Expand Down
17 changes: 16 additions & 1 deletion helix-term/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -226,13 +226,28 @@ impl MappableCommand {
match &self {
Self::Typable { name, args, doc: _ } => {
let args: Vec<Cow<str>> = args.iter().map(Cow::from).collect();
let mut joined_args = args.join(" ");
let expanded_args = match args.len() {
0 => vec![],
_ => {
if let Ok(expanded) = cx.editor.expand_variable_in_string(&joined_args) {
joined_args = expanded.to_string();
joined_args.split(' ').map(Cow::from).collect()
} else {
args
}
}
};
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) {

if let Err(e) =
(command.fun)(&mut cx, &expanded_args[..], PromptEvent::Validate)
{
cx.editor.set_error(format!("{}", e));
}
}
Expand Down
33 changes: 31 additions & 2 deletions helix-term/src/commands/typed.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2520,6 +2520,18 @@ fn read(cx: &mut compositor::Context, args: &[Cow<str>], event: PromptEvent) ->
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] = &[
TypableCommand {
name: "quit",
Expand Down Expand Up @@ -3141,6 +3153,13 @@ pub const TYPABLE_COMMAND_LIST: &[TypableCommand] = &[
fun: read,
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>> =
Expand Down Expand Up @@ -3220,8 +3239,18 @@ 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 args = shellwords.words();

let words = shellwords.words().to_vec();
let args = if event == PromptEvent::Validate {
match cx.editor.expand_variables_in_vec(&words) {
Ok(args) => args,
Err(e) => {
cx.editor.set_error(format!("{}", e));
return;
}
}
} else {
words
};
if let Err(e) = (cmd.fun)(cx, &args[1..], event) {
cx.editor.set_error(format!("{}", e));
}
Expand Down
1 change: 1 addition & 0 deletions helix-term/tests/test/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ use helix_term::application::Application;
use super::*;

mod movement;
mod variable_expansion;
mod write;

#[tokio::test(flavor = "multi_thread")]
Expand Down
Loading