Rewrite command line parsing, add flags and expansions (#12527)

Co-authored-by: Pascal Kuthe <pascalkuthe@pm.me>
This commit is contained in:
Michael Davis 2025-02-26 20:50:15 -05:00 committed by GitHub
parent e1c7a1ed77
commit 0efa8207d8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 2707 additions and 909 deletions

View file

@ -12,6 +12,7 @@
- [Syntax aware motions](./syntax-aware-motions.md)
- [Pickers](./pickers.md)
- [Keymap](./keymap.md)
- [Command line](./command-line.md)
- [Commands](./commands.md)
- [Language support](./lang-support.md)
- [Migrating from Vim](./from-vim.md)

82
book/src/command-line.md Normal file
View file

@ -0,0 +1,82 @@
# Command line
- [Quoting](#quoting)
- [Flags](#flags)
- [Expansions](#expansions)
- [Exceptions](#exceptions)
The command line is used for executing [typable commands](./commands.md#typable-commands) like `:write` or `:quit`. Press `:` to activate the command line.
Typable commands optionally accept arguments. `:write` for example accepts an optional path to write the file contents. The command line also supports a quoting syntax for arguments, flags to modify command behaviors, and _expansions_ - a way to insert values from the editor. Most commands support these features but some have custom parsing rules (see the [exceptions](#exceptions) below).
## Quoting
By default, command arguments are split on tabs and space characters. `:open README.md CHANGELOG.md` for example should open two files, `README.md` and `CHANGELOG.md`. Arguments that contain spaces can be surrounded in single quotes (`'`) or backticks (`` ` ``) to prevent the space from separating the argument, like `:open 'a b.txt'`.
Double quotes may be used the same way, but double quotes _expand_ their inner content. `:echo "%{cursor_line}"` for example may print `1` because of the expansion for the `cursor_line` variable. `:echo '%{cursor_line}'` though prints `%{cursor_line}` literally: content within single quotes or backticks is interpreted as-is.
On Unix systems the backslash character may be used to escape certain characters depending on where it is used. Within an argument which isn't surround in quotes, the backslash can be used to escape the space or tab characters: `:open a\ b.txt` is equivalent to `:open 'a b.txt'`. The backslash may also be used to escape quote characters (`'`, `` ` ``, `"`) or the percent token (`%`) when used at the beginning of an argument. `:echo \%%sh{foo}` for example prints `%sh{foo}` instead of invoking a `foo` shell command and `:echo \"quote` prints `"quote`. The backslash character is treated literally in any other situation on Unix systems and always on Windows: `:echo \n` always prints `\n`.
## Flags
Command flags are optional switches that can be used to alter the behavior of a command. For example the `:sort` command accepts an optional `--reverse` (or `-r` for short) flag which causes the sort command to reverse the sorting direction. Typing the `-` character shows completions for the current command's flags, if any.
The `--` flag specifies the end of flags. All arguments after `--` are treated as positional arguments: `:open -- -a.txt` opens a file called `-a.txt`.
## Expansions
Expansions are patterns that Helix recognizes and replaces within the command line. Helix recognizes anything starting with a percent token (`%`) as an expansion, for example `%sh{echo hi!}`. Expansions are particularly useful when used in commands like `:echo` or `:noop` for executing simple scripts. For example:
```toml
[keys.normal]
# Print the current line's git blame information to the statusline.
space.B = ":echo %sh{git blame -L %{cursor_line},+1 %{buffer_name}}"
```
Expansions take the form `%[<kind>]<open><contents><close>`. In `%sh{echo hi!}`, for example, the kind is `sh` - the shell expansion - and the contents are "echo hi!", with `{` and `}` acting as opening and closing delimiters. The following open/close characters are recognized as expansion delimiter pairs: `(`/`)`, `[`/`]`, `{`/`}` and `<`/`>`. Plus the single characters `'`, `"` or `|` may be used instead: `%{cursor_line}` is equivalent to `%<cursor_line>`, `%[cursor_line]` or `%|cursor_line|`.
To escape a percent character instead of treating it as an expansion, use two percent characters consecutively. To execute a shell command like `date -u +'%Y-%m-%d'`, double the percent characters: `:echo %sh{date -u +'%%Y-%%m-%%d'}`.
When no `<kind>` is provided, Helix will expand a **variable**. For example `%{cursor_line}` can be used as in argument to insert the line number. `:echo %{cursor_line}` for instance may print `1` to the statusline.
The following variables are supported:
| Name | Description |
|--- |--- |
| `cursor_line` | The line number of the primary cursor in the currently focused document, starting at 1. |
| `cursor_column` | The column number of the primary cursor in the currently focused document, starting at 1. This is counted as the number of grapheme clusters from the start of the line rather than bytes or codepoints. |
| `buffer_name` | The relative path of the currently focused document. `[scratch]` is expanded instead for scratch buffers. |
| `line_ending` | A string containing the line ending of the currently focused document. For example on Unix systems this is usually a line-feed character (`\n`) but on Windows systems this may be a carriage-return plus a line-feed (`\r\n`). The line ending kind of the currently focused document can be inspected with the `:line-ending` command. |
Aside from editor variables, the following expansions may be used:
* Unicode `%u{..}`. The contents may contain up to six hexadecimal numbers corresponding to a Unicode codepoint value. For example `:echo %u{25CF}` prints `●` to the statusline.
* Shell `%sh{..}`. The contents are passed to the configured shell command. For example `:echo %sh{echo "20 * 5" | bc}` may print `100` on the statusline on when using a shell with `echo` and the `bc` calculator installed. Shell expansions are evaluated recursively. `%sh{echo '%{buffer_name}:%{cursor_line}'}` for example executes a command like `echo 'README.md:1'`: the variables within the `%sh{..}` expansion are evaluated before executing the shell command.
As mentioned above, double quotes can be used to surround arguments containing spaces but also support expansions within the quoted content unlike singe quotes or backticks. For example `:echo "circle: %u{25CF}"` prints `circle: ●` to the statusline while `:echo 'circle: %u{25CF}'` prints `circle: %u{25CF}`.
Note that expansions are only evaluated once the Enter key is pressed in command mode.
## Exceptions
The following commands support expansions but otherwise pass the given argument directly to the shell program without interpreting quotes:
* `:insert-output`
* `:append-output`
* `:pipe`
* `:pipe-to`
* `:run-shell-command`
For example executing `:sh echo "%{buffer_name}:%{cursor_column}"` would pass text like `echo "README.md:1"` as an argument to the shell program: the expansions are evaluated but not the quotes. As mentioned above, percent characters can be used in shell commands by doubling the percent character. To insert the output of a command like `date -u +'%Y-%m-%d'` use `:insert-output date -u +'%%Y-%%m-%%d'`.
The `:set-option` and `:toggle-option` commands use regular parsing for the first argument - the config option name - and parse the rest depending on the config option's type. `:set-option` interprets the second argument as a string for string config options and parses everything else as JSON.
`:toggle-option`'s behavior depends on the JSON type of the config option supplied as the first argument:
* Booleans: only the config option name should be provided. For example `:toggle-option auto-format` will flip the `auto-format` option.
* Strings: the rest of the command line is parsed with regular quoting rules. For example `:toggle-option indent-heuristic hybrid tree-sitter simple` cycles through "hybrid", "tree-sitter" and "simple" values on each invocation of the command.
* Numbers, arrays and objects: the rest of the command line is parsed as a stream of JSON values. For example `:toggle-option rulers [81] [51, 73]` cycles through `[81]` and `[51, 73]`.
When providing multiple values to `:toggle-option` there should be no duplicates. `:toggle-option indent-heuristic hybrid simple tree-sitter simple` for example would only toggle between "hybrid" and "tree-sitter" values.
`:lsp-workspace-command` works similarly to `:toggle-option`. The first argument (if present) is parsed according to normal rules. The rest of the line is parsed as JSON values. Unlike `:toggle-option`, string arguments for a command must be quoted. For example `:lsp-workspace-command lsp.Command "foo" "bar"`.

View file

@ -67,10 +67,9 @@
| `:goto`, `:g` | Goto line number. |
| `:set-language`, `:lang` | Set the language of current buffer (show current language if no value specified). |
| `:set-option`, `:set` | Set a config option at runtime.<br>For example to disable smart case search, use `:set search.smart-case false`. |
| `:toggle-option`, `:toggle` | Toggle a boolean config option at runtime.<br>For example to toggle smart case search, use `:toggle search.smart-case`. |
| `:toggle-option`, `:toggle` | Toggle a config option at runtime.<br>For example to toggle smart case search, use `:toggle search.smart-case`. |
| `:get-option`, `:get` | Get the current value of a config option. |
| `:sort` | Sort ranges in selection. |
| `:rsort` | Sort ranges in selection in reverse order. |
| `:reflow` | Hard-wrap the current selection of lines to a given width. |
| `:tree-sitter-subtree`, `:ts-subtree` | Display the smallest tree-sitter subtree that spans the primary selection, primarily for debugging queries. |
| `:config-reload` | Refresh user config. |
@ -88,3 +87,5 @@
| `: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` | Prints the given arguments to the statusline. |
| `:noop` | Does nothing. |

File diff suppressed because it is too large Load diff

View file

@ -3,6 +3,7 @@ pub use encoding_rs as encoding;
pub mod auto_pairs;
pub mod case_conversion;
pub mod chars;
pub mod command_line;
pub mod comment;
pub mod completion;
pub mod config;
@ -22,7 +23,6 @@ pub mod object;
mod position;
pub mod search;
pub mod selection;
pub mod shellwords;
pub mod snippets;
pub mod surround;
pub mod syntax;

View file

@ -1,350 +0,0 @@
use std::borrow::Cow;
/// Auto escape for shellwords usage.
pub fn escape(input: Cow<str>) -> Cow<str> {
if !input.chars().any(|x| x.is_ascii_whitespace()) {
input
} else if cfg!(unix) {
Cow::Owned(input.chars().fold(String::new(), |mut buf, c| {
if c.is_ascii_whitespace() {
buf.push('\\');
}
buf.push(c);
buf
}))
} else {
Cow::Owned(format!("\"{}\"", input))
}
}
enum State {
OnWhitespace,
Unquoted,
UnquotedEscaped,
Quoted,
QuoteEscaped,
Dquoted,
DquoteEscaped,
}
pub struct Shellwords<'a> {
state: State,
/// Shellwords where whitespace and escapes has been resolved.
words: Vec<Cow<'a, str>>,
/// The parts of the input that are divided into shellwords. This can be
/// used to retrieve the original text for a given word by looking up the
/// same index in the Vec as the word in `words`.
parts: Vec<&'a str>,
}
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 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
}
'\'' => {
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
}
_ => 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
}
}
'\'' => {
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,
};
let c_len = c.len_utf8();
if i == input.len() - c_len && end == 0 {
end = i + c_len;
}
if end > 0 {
let esc_trim = escaped.trim();
let inp = &input[unescaped_start..end];
if !(esc_trim.is_empty() && inp.trim().is_empty()) {
if esc_trim.is_empty() {
words.push(inp.into());
parts.push(inp);
} else {
words.push([escaped, inp.into()].concat().into());
parts.push(&input[part_start..end]);
escaped = "".to_string();
}
}
unescaped_start = i + 1;
part_start = i + 1;
end = 0;
}
}
debug_assert!(words.len() == parts.len());
Self {
state,
words,
parts,
}
}
}
impl<'a> Shellwords<'a> {
/// Checks that the input ends with a whitespace character which is not escaped.
///
/// # Examples
///
/// ```rust
/// use helix_core::shellwords::Shellwords;
/// assert_eq!(Shellwords::from(" ").ends_with_whitespace(), true);
/// assert_eq!(Shellwords::from(":open ").ends_with_whitespace(), true);
/// assert_eq!(Shellwords::from(":open foo.txt ").ends_with_whitespace(), true);
/// assert_eq!(Shellwords::from(":open").ends_with_whitespace(), false);
/// #[cfg(unix)]
/// assert_eq!(Shellwords::from(":open a\\ ").ends_with_whitespace(), false);
/// #[cfg(unix)]
/// assert_eq!(Shellwords::from(":open a\\ b.txt").ends_with_whitespace(), false);
/// ```
pub fn ends_with_whitespace(&self) -> bool {
matches!(self.state, State::OnWhitespace)
}
/// Returns the list of shellwords calculated from the input string.
pub fn words(&self) -> &[Cow<'a, str>] {
&self.words
}
/// Returns a list of strings which correspond to [`Self::words`] but represent the original
/// text in the input string - including escape characters - without separating whitespace.
pub fn parts(&self) -> &[&'a str] {
&self.parts
}
}
#[cfg(test)]
mod test {
use super::*;
#[test]
#[cfg(windows)]
fn test_normal() {
let input = r#":o single_word twó wörds \three\ \"with\ escaping\\"#;
let shellwords = Shellwords::from(input);
let result = shellwords.words().to_vec();
let expected = vec![
Cow::from(":o"),
Cow::from("single_word"),
Cow::from("twó"),
Cow::from("wörds"),
Cow::from("\\three\\"),
Cow::from("\\"),
Cow::from("with\\ escaping\\\\"),
];
// TODO test is_owned and is_borrowed, once they get stabilized.
assert_eq!(expected, result);
}
#[test]
#[cfg(unix)]
fn test_normal() {
let input = r#":o single_word twó wörds \three\ \"with\ escaping\\"#;
let shellwords = Shellwords::from(input);
let result = shellwords.words().to_vec();
let expected = vec![
Cow::from(":o"),
Cow::from("single_word"),
Cow::from("twó"),
Cow::from("wörds"),
Cow::from(r#"three "with escaping\"#),
];
// TODO test is_owned and is_borrowed, once they get stabilized.
assert_eq!(expected, result);
}
#[test]
#[cfg(unix)]
fn test_quoted() {
let quoted =
r#":o 'single_word' 'twó wörds' '' ' ''\three\' \"with\ escaping\\' 'quote incomplete"#;
let shellwords = Shellwords::from(quoted);
let result = shellwords.words().to_vec();
let expected = vec![
Cow::from(":o"),
Cow::from("single_word"),
Cow::from("twó wörds"),
Cow::from(r#"three' "with escaping\"#),
Cow::from("quote incomplete"),
];
assert_eq!(expected, result);
}
#[test]
#[cfg(unix)]
fn test_dquoted() {
let dquoted = r#":o "single_word" "twó wörds" "" " ""\three\' \"with\ escaping\\" "dquote incomplete"#;
let shellwords = Shellwords::from(dquoted);
let result = shellwords.words().to_vec();
let expected = vec![
Cow::from(":o"),
Cow::from("single_word"),
Cow::from("twó wörds"),
Cow::from(r#"three' "with escaping\"#),
Cow::from("dquote incomplete"),
];
assert_eq!(expected, result);
}
#[test]
#[cfg(unix)]
fn test_mixed() {
let dquoted = r#":o single_word 'twó wörds' "\three\' \"with\ escaping\\""no space before"'and after' $#%^@ "%^&(%^" ')(*&^%''a\\\\\b' '"#;
let shellwords = Shellwords::from(dquoted);
let result = shellwords.words().to_vec();
let expected = vec![
Cow::from(":o"),
Cow::from("single_word"),
Cow::from("twó wörds"),
Cow::from("three' \"with escaping\\"),
Cow::from("no space before"),
Cow::from("and after"),
Cow::from("$#%^@"),
Cow::from("%^&(%^"),
Cow::from(")(*&^%"),
Cow::from(r#"a\\b"#),
//last ' just changes to quoted but since we dont have anything after it, it should be ignored
];
assert_eq!(expected, result);
}
#[test]
fn test_lists() {
let input =
r#":set statusline.center ["file-type","file-encoding"] '["list", "in", "quotes"]'"#;
let shellwords = Shellwords::from(input);
let result = shellwords.words().to_vec();
let expected = vec![
Cow::from(":set"),
Cow::from("statusline.center"),
Cow::from(r#"["file-type","file-encoding"]"#),
Cow::from(r#"["list", "in", "quotes"]"#),
];
assert_eq!(expected, result);
}
#[test]
#[cfg(unix)]
fn test_escaping_unix() {
assert_eq!(escape("foobar".into()), Cow::Borrowed("foobar"));
assert_eq!(escape("foo bar".into()), Cow::Borrowed("foo\\ bar"));
assert_eq!(escape("foo\tbar".into()), Cow::Borrowed("foo\\\tbar"));
}
#[test]
#[cfg(windows)]
fn test_escaping_windows() {
assert_eq!(escape("foobar".into()), Cow::Borrowed("foobar"));
assert_eq!(escape("foo bar".into()), Cow::Borrowed("\"foo bar\""));
}
#[test]
#[cfg(unix)]
fn test_parts() {
assert_eq!(Shellwords::from(":o a").parts(), &[":o", "a"]);
assert_eq!(Shellwords::from(":o a\\ ").parts(), &[":o", "a\\ "]);
}
#[test]
#[cfg(windows)]
fn test_parts() {
assert_eq!(Shellwords::from(":o a").parts(), &[":o", "a"]);
assert_eq!(Shellwords::from(":o a\\ ").parts(), &[":o", "a\\"]);
}
#[test]
fn test_multibyte_at_end() {
assert_eq!(Shellwords::from("𒀀").parts(), &["𒀀"]);
assert_eq!(
Shellwords::from(":sh echo 𒀀").parts(),
&[":sh", "echo", "𒀀"]
);
assert_eq!(
Shellwords::from(":sh echo 𒀀 hello world𒀀").parts(),
&[":sh", "echo", "𒀀", "hello", "world𒀀"]
);
}
}

View file

@ -20,7 +20,7 @@ pub use typed::*;
use helix_core::{
char_idx_at_visual_offset,
chars::char_is_word,
comment,
command_line, comment,
doc_formatter::TextFormat,
encoding, find_workspace,
graphemes::{self, next_grapheme_boundary},
@ -33,7 +33,7 @@ use helix_core::{
object, pos_at_coords,
regex::{self, Regex},
search::{self, CharMatcher},
selection, shellwords, surround,
selection, surround,
syntax::{BlockCommentToken, LanguageServerFeature},
text_annotations::{Overlay, TextAnnotations},
textobject,
@ -58,7 +58,6 @@ use insert::*;
use movement::Movement;
use crate::{
args,
compositor::{self, Component, Compositor},
filter_picker_entry,
job::Callback,
@ -211,7 +210,7 @@ use helix_view::{align_view, Align};
pub enum MappableCommand {
Typable {
name: String,
args: Vec<String>,
args: String,
doc: String,
},
Static {
@ -246,16 +245,19 @@ impl MappableCommand {
pub fn execute(&self, cx: &mut Context) {
match &self {
Self::Typable { name, args, doc: _ } => {
let args: Vec<Cow<str>> = 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) {
if let Err(e) =
typed::execute_command(&mut cx, command, args, PromptEvent::Validate)
{
cx.editor.set_error(format!("{}", e));
}
} else {
cx.editor.set_error(format!("no such command: '{name}'"));
}
}
Self::Static { fun, .. } => (fun)(cx),
@ -629,13 +631,8 @@ impl std::str::FromStr for MappableCommand {
fn from_str(s: &str) -> Result<Self, Self::Err> {
if let Some(suffix) = s.strip_prefix(':') {
let mut typable_command = suffix.split(' ').map(|arg| arg.trim());
let name = typable_command
.next()
.ok_or_else(|| anyhow!("Expected typable command name"))?;
let args = typable_command
.map(|s| s.to_owned())
.collect::<Vec<String>>();
let (name, args, _) = command_line::split(suffix);
ensure!(!name.is_empty(), "Expected typable command name");
typed::TYPABLE_COMMAND_MAP
.get(name)
.map(|cmd| {
@ -647,7 +644,7 @@ impl std::str::FromStr for MappableCommand {
MappableCommand::Typable {
name: cmd.name.to_owned(),
doc,
args,
args: args.to_string(),
}
})
.ok_or_else(|| anyhow!("No TypableCommand named '{}'", s))
@ -3384,7 +3381,7 @@ pub fn command_palette(cx: &mut Context) {
.iter()
.map(|cmd| MappableCommand::Typable {
name: cmd.name.to_owned(),
args: Vec::new(),
args: String::new(),
doc: cmd.doc.to_owned(),
}),
);

File diff suppressed because it is too large Load diff

View file

@ -601,11 +601,7 @@ mod tests {
MappableCommand::select_all,
MappableCommand::Typable {
name: "pipe".to_string(),
args: vec!{
"sed".to_string(),
"-E".to_string(),
"'s/\\s+$//g'".to_string()
},
args: "sed -E 's/\\s+$//g'".to_string(),
doc: "".to_string(),
},
})

View file

@ -17,9 +17,9 @@ mod test {
mod auto_indent;
mod auto_pairs;
mod command_line;
mod commands;
mod languages;
mod movement;
mod prompt;
mod splits;
}

View file

@ -0,0 +1,92 @@
use super::*;
use helix_core::diagnostic::Severity;
#[tokio::test(flavor = "multi_thread")]
async fn history_completion() -> anyhow::Result<()> {
test_key_sequence(
&mut AppBuilder::new().build()?,
Some(":asdf<ret>:theme d<C-n><tab>"),
Some(&|app| {
assert!(!app.editor.is_err());
}),
false,
)
.await?;
Ok(())
}
async fn test_statusline(
line: &str,
expected_status: &str,
expected_severity: Severity,
) -> anyhow::Result<()> {
test_key_sequence(
&mut AppBuilder::new().build()?,
Some(&format!("{line}<ret>")),
Some(&|app| {
let (status, &severity) = app.editor.get_status().unwrap();
assert_eq!(
severity, expected_severity,
"'{line}' printed {severity:?}: {status}"
);
assert_eq!(status.as_ref(), expected_status);
}),
false,
)
.await
}
#[tokio::test(flavor = "multi_thread")]
async fn variable_expansion() -> anyhow::Result<()> {
test_statusline(r#":echo %{cursor_line}"#, "1", Severity::Info).await?;
// Double quotes can be used with expansions:
test_statusline(
r#":echo "line%{cursor_line}line""#,
"line1line",
Severity::Info,
)
.await?;
// Within double quotes you can escape the percent token for an expansion by doubling it.
test_statusline(
r#":echo "%%{cursor_line}""#,
"%{cursor_line}",
Severity::Info,
)
.await?;
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn unicode_expansion() -> anyhow::Result<()> {
test_statusline(r#":echo %u{20}"#, " ", Severity::Info).await?;
test_statusline(r#":echo %u{0020}"#, " ", Severity::Info).await?;
test_statusline(r#":echo %u{25CF}"#, "", Severity::Info).await?;
// Not a valid Unicode codepoint:
test_statusline(
r#":echo %u{deadbeef}"#,
"'echo': could not interpret 'deadbeef' as a Unicode character code",
Severity::Error,
)
.await?;
Ok(())
}
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread")]
async fn shell_expansion() -> anyhow::Result<()> {
test_statusline(
r#":echo %sh{echo "hello world"}"#,
"hello world",
Severity::Info,
)
.await?;
// Shell expansion is recursive.
test_statusline(":echo %sh{echo '%{cursor_line}'}", "1", Severity::Info).await?;
Ok(())
}

View file

@ -1,16 +0,0 @@
use super::*;
#[tokio::test(flavor = "multi_thread")]
async fn test_history_completion() -> anyhow::Result<()> {
test_key_sequence(
&mut AppBuilder::new().build()?,
Some(":asdf<ret>:theme d<C-n><tab>"),
Some(&|app| {
assert!(!app.editor.is_err());
}),
false,
)
.await?;
Ok(())
}

219
helix-view/src/expansion.rs Normal file
View file

@ -0,0 +1,219 @@
use std::borrow::Cow;
use helix_core::command_line::{ExpansionKind, Token, TokenKind, Tokenizer};
use anyhow::{anyhow, bail, Result};
use crate::Editor;
/// Variables that can be expanded in the command mode (`:`) via the expansion syntax.
///
/// For example `%{cursor_line}`.
//
// To add a new variable follow these steps:
//
// * Add the new enum member to `Variable` below.
// * Add an item to the `VARIANTS` constant - this enables completion.
// * Add a branch in `Variable::as_str`, converting the name from TitleCase to snake_case.
// * Add a branch in `Variable::from_name` with the reverse association.
// * Add a branch in the `expand_variable` function to read the value from the editor.
// * Add the new variable to the documentation in `book/src/command-line.md`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Variable {
/// The one-indexed line number of the primary cursor in the currently focused document.
CursorLine,
/// The one-indexed column number of the primary cursor in the currently focused document.
///
/// Note that this is the count of grapheme clusters from the start of the line (regardless of
/// softwrap) - the same as the `position` element in the statusline.
CursorColumn,
/// The display name of the currently focused document.
///
/// This corresponds to `crate::Document::display_name`.
BufferName,
/// A string containing the line-ending of the currently focused document.
LineEnding,
}
impl Variable {
pub const VARIANTS: &'static [Self] = &[
Self::CursorLine,
Self::CursorColumn,
Self::BufferName,
Self::LineEnding,
];
pub const fn as_str(&self) -> &'static str {
match self {
Self::CursorLine => "cursor_line",
Self::CursorColumn => "cursor_column",
Self::BufferName => "buffer_name",
Self::LineEnding => "line_ending",
}
}
pub fn from_name(s: &str) -> Option<Self> {
match s {
"cursor_line" => Some(Self::CursorLine),
"cursor_column" => Some(Self::CursorColumn),
"buffer_name" => Some(Self::BufferName),
"line_ending" => Some(Self::LineEnding),
_ => None,
}
}
}
/// Expands the given command line token.
///
/// Note that the lifetime of the expanded variable is only bound to the input token and not the
/// `Editor`. See `expand_variable` below for more discussion of lifetimes.
pub fn expand<'a>(editor: &Editor, token: Token<'a>) -> Result<Cow<'a, str>> {
// Note: see the `TokenKind` documentation for more details on how each branch should expand.
match token.kind {
TokenKind::Unquoted | TokenKind::Quoted(_) => Ok(token.content),
TokenKind::Expansion(ExpansionKind::Variable) => {
let var = Variable::from_name(&token.content)
.ok_or_else(|| anyhow!("unknown variable '{}'", token.content))?;
expand_variable(editor, var)
}
TokenKind::Expansion(ExpansionKind::Unicode) => {
if let Some(ch) = u32::from_str_radix(token.content.as_ref(), 16)
.ok()
.and_then(char::from_u32)
{
Ok(Cow::Owned(ch.to_string()))
} else {
Err(anyhow!(
"could not interpret '{}' as a Unicode character code",
token.content
))
}
}
TokenKind::Expand => expand_inner(editor, token.content),
TokenKind::Expansion(ExpansionKind::Shell) => expand_shell(editor, token.content),
// Note: see the docs for this variant.
TokenKind::ExpansionKind => unreachable!(
"expansion name tokens cannot be emitted when command line validation is enabled"
),
}
}
/// Expand a shell command.
pub fn expand_shell<'a>(editor: &Editor, content: Cow<'a, str>) -> Result<Cow<'a, str>> {
use std::process::{Command, Stdio};
// Recursively expand the expansion's content before executing the shell command.
let content = expand_inner(editor, content)?;
let config = editor.config();
let shell = &config.shell;
let mut process = Command::new(&shell[0]);
process
.args(&shell[1..])
.arg(content.as_ref())
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
// TODO: there is no protection here against a shell command taking a long time.
// Ideally you should be able to hit `<ret>` in command mode and then be able to
// cancel the invocation (for example with `<C-c>`) if it takes longer than you'd
// like.
let output = match process.spawn() {
Ok(process) => process.wait_with_output()?,
Err(err) => {
bail!("Failed to start shell: {err}");
}
};
let mut text = String::from_utf8_lossy(&output.stdout).into_owned();
if !output.stderr.is_empty() {
log::warn!(
"Shell expansion command `{content}` failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
// Trim exactly one trailing line ending if it exists.
if text.ends_with('\n') {
text.pop();
if text.ends_with('\r') {
text.pop();
}
}
Ok(Cow::Owned(text))
}
/// Expand a token's contents recursively.
fn expand_inner<'a>(editor: &Editor, content: Cow<'a, str>) -> Result<Cow<'a, str>> {
let mut escaped = String::new();
let mut start = 0;
while let Some(offset) = content[start..].find('%') {
let idx = start + offset;
if content.as_bytes().get(idx + '%'.len_utf8()).copied() == Some(b'%') {
// Treat two percents in a row as an escaped percent.
escaped.push_str(&content[start..=idx]);
// Skip over both percents.
start = idx + ('%'.len_utf8() * 2);
} else {
// Otherwise interpret the percent as an expansion. Push up to (but not
// including) the percent token.
escaped.push_str(&content[start..idx]);
// Then parse the expansion,
let mut tokenizer = Tokenizer::new(&content[idx..], true);
let token = tokenizer
.parse_percent_token()
.unwrap()
.map_err(|err| anyhow!("{err}"))?;
// expand it (this is the recursive part),
let expanded = expand(editor, token)?;
escaped.push_str(expanded.as_ref());
// and move forward to the end of the expansion.
start = idx + tokenizer.pos();
}
}
if escaped.is_empty() {
Ok(content)
} else {
escaped.push_str(&content[start..]);
Ok(Cow::Owned(escaped))
}
}
// Note: the lifetime of the expanded variable (the `Cow`) must not be tied to the lifetime of
// the borrow of `Editor`. That would prevent commands from mutating the `Editor` until the
// command consumed or cloned all arguments - this is poor ergonomics. A sensible thing for this
// function to return then, instead, would normally be a `String`. We can return some statically
// known strings like the scratch buffer name or line ending strings though, so this function
// returns a `Cow<'static, str>` instead.
fn expand_variable(editor: &Editor, variable: Variable) -> Result<Cow<'static, str>> {
let (view, doc) = current_ref!(editor);
let text = doc.text().slice(..);
match variable {
Variable::CursorLine => {
let cursor_line = doc.selection(view.id).primary().cursor_line(text);
Ok(Cow::Owned((cursor_line + 1).to_string()))
}
Variable::CursorColumn => {
let cursor = doc.selection(view.id).primary().cursor(text);
let position = helix_core::coords_at_pos(text, cursor);
Ok(Cow::Owned((position.col + 1).to_string()))
}
Variable::BufferName => {
// Note: usually we would use `Document::display_name` but we can statically borrow
// the scratch buffer name by partially reimplementing `display_name`.
if let Some(path) = doc.relative_path() {
Ok(Cow::Owned(path.to_string_lossy().into_owned()))
} else {
Ok(Cow::Borrowed(crate::document::SCRATCH_BUFFER_NAME))
}
}
Variable::LineEnding => Ok(Cow::Borrowed(doc.line_ending.as_str())),
}
}

View file

@ -7,6 +7,7 @@ pub mod clipboard;
pub mod document;
pub mod editor;
pub mod events;
pub mod expansion;
pub mod graphics;
pub mod gutter;
pub mod handlers;