diff --git a/book/src/commands.md b/book/src/commands.md index ee507276b51f2..338d28c735cbc 100644 --- a/book/src/commands.md +++ b/book/src/commands.md @@ -1,11 +1,87 @@ # Commands - [Typable commands](#typable-commands) + - [Command mode syntax](#command-mode-syntax) + - [Quoting](#quoting) + - [Expansions](#expansions) + - [Flags](#flags) + - [Exceptions](#exceptions) + - [Built-ins](#built-ins) - [Static commands](#static-commands) ## Typable commands -Typable commands are used from command mode and may take arguments. Command mode can be activated by pressing `:`. The built-in typable commands are: +Typable commands are used from command mode and may take arguments. Command mode can be activated by pressing `:`. + +### Command mode syntax + +Command mode has rules for parsing the command line to evaluate quotes and expansions and split the line into positional arguments and flags. Most commands use these rules but some commands have custom parsing rules (see [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` since the variable expansion within is expanded. `:echo '%{cursor_line}'` prints `%{cursor_line}` literally though. 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`. + +#### 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 take the form `%[]`. 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 `<`/`>`. Any grapheme cluster can be used as both open and close delimiters instead however: `%{cursor_line}` is equivalent to `%|cursor_line|`, `%"cursor_line"` and even `%🏴‍☠️cursor_line🏴‍☠️`. + +When no `` is provided, Helix will expand a **variable**. For example `%{cursor_line}` can be used as an argument to provide the currently focused document's primary selection cursor line as an argument. `:echo %{cursor_line}` for instance may print `1` to the statusline. + +The following variables are supported: + +| Name | Description | +|--- |--- | +| `cursor_line` | The one-indexed line number of the primary cursor in the currently focused document. | +| `cursor_column` | The one-indexed column number of the primary cursor in the currently focused document. 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 with 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. + +#### 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, if present, 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`. + +#### 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. + +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. +* 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. + +### Built-ins + +The built-in typable commands are: {{#include ./generated/typable-cmd.md}} diff --git a/book/src/generated/typable-cmd.md b/book/src/generated/typable-cmd.md index f0d9a0f492a53..9133bf9e3ac17 100644 --- a/book/src/generated/typable-cmd.md +++ b/book/src/generated/typable-cmd.md @@ -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.
For example to disable smart case search, use `:set search.smart-case false`. | -| `:toggle-option`, `:toggle` | Toggle a boolean config option at runtime.
For example to toggle smart case search, use `:toggle search.smart-case`. | +| `:toggle-option`, `:toggle` | Toggle a config option at runtime.
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. | diff --git a/helix-core/src/command_line.rs b/helix-core/src/command_line.rs new file mode 100644 index 0000000000000..ffbbddac9d5d1 --- /dev/null +++ b/helix-core/src/command_line.rs @@ -0,0 +1,1219 @@ +//! Types and parsing code for command mode (`:`) input. +//! +//! Command line parsing is done in steps: +//! +//! * The `Tokenizer` iterator returns `Token`s from the command line input naively - without +//! accounting for a command's signature. +//! * When executing a command (pressing `` in command mode), tokens are expanded with +//! information from the editor like the current cursor line or column. Otherwise the tokens +//! are unwrapped to their inner content. +//! * `Args` interprets the contents (potentially expanded) as flags or positional arguments. +//! When executing a command, `Args` performs validations like checking the number of positional +//! arguments supplied and whether duplicate or unknown flags were supplied. +//! +//! `Args` is the interface used by typable command implementations. `Args` may be treated as a +//! slice of `Cow` or `&str` to access positional arguments, for example `for arg in args` +//! iterates over positional args (never flags) and `&args[0]` always corresponds to the first +//! positional. Use `Args::has_flag` and `Args::get_flag` to read any specified flags. +//! +//! `Args` and `Tokenizer` are intertwined. `Args` may ask the `Tokenizer` for the rest of the +//! command line as a single token after the configured number of positionals has been reached +//! (according to `raw_after`). This is used for the custom parsing in `:set-option` and +//! `:toggle-option` for example. Outside of executing commands, the `Tokenizer` can be used +//! directly to interpret a string according to the regular tokenization rules. +//! +//! This module also defines structs for configuring the parsing of the command line for a +//! command. See `Flag` and `Signature`. + +use std::{borrow::Cow, collections::HashMap, error::Error, fmt, ops, vec}; + +use unicode_segmentation::UnicodeSegmentation; + +/// Splits a command line into the command and arguments parts. +/// +/// The third tuple member describes whether the command part is finished. When this boolean is +/// true the completion code for the command line should complete command names, otherwise +/// command arguments. +pub fn split(line: &str) -> (&str, &str, bool) { + const SEPARATOR_PATTERN: [char; 2] = [' ', '\t']; + + let (command, rest) = line.split_once(SEPARATOR_PATTERN).unwrap_or((line, "")); + + let complete_command = + command.is_empty() || (rest.trim().is_empty() && !line.ends_with(SEPARATOR_PATTERN)); + + (command, rest, complete_command) +} + +/// A Unix-like flag that a command may accept. +/// +/// For example the `:sort` command accepts a `--reverse` (or `-r` for shorthand) boolean flag +/// which controls the direction of sorting. Flags may accept an argument by setting the +/// `completions` field to `Some`. +#[derive(Debug, Clone, Copy)] +pub struct Flag { + /// The name of the flag. + /// + /// This value is also used to construct the "longhand" version of the flag. For example a + /// flag with a name "reverse" has a longhand `--reverse`. + /// + /// This value should be supplied when reading a flag out of the [Args] with [Args::get_flag] + /// and [Args::has_flag]. The `:sort` command implementation for example should ask for + /// `args.has_flag("reverse")`. + pub name: &'static str, + /// The character that can be used as a shorthand for the flag, optionally. + /// + /// For example a flag like "reverse" mentioned above might take an alias `Some('r')` to + /// allow specifying the flag as `-r`. + pub alias: Option, + pub doc: &'static str, + /// The completion values to use when specifying an argument for a flag. + /// + /// This should be set to `None` for boolean flags and `Some(&["foo", "bar", "baz"])` for + /// example for flags which accept options, with the strings corresponding to values that + /// should be shown in completion. + pub completions: Option<&'static [&'static str]>, +} + +impl Flag { + // This allows defining flags with the `..Flag::default()` shorthand. The `name` and `doc` + // fields should always be overwritten. + pub const fn default() -> Self { + Self { + name: "", + doc: "", + alias: None, + completions: None, + } + } +} + +/// A description of how a command's input should be handled. +/// +/// Each typable command defines a signature (usually with the help of `Signature::default` or +/// `Signature::raw`) at least to declare how many positional arguments it accepts. Command flags +/// are also declared in this struct. The `raw_after` option may be set optionally to avoid +/// evaluating quotes in parts of the command line (useful for shell commands for example). +#[derive(Debug, Clone, Copy)] +pub struct Signature { + /// The minimum and optionally maximum number of positional arguments a command may take. + /// + /// For example accepting exactly one positional can be specified with `(1, Some(1))` while + /// accepting zero-or-more positionals can be specified as `(0, None)`. + pub positionals: (usize, Option), + /// The number of **positional** arguments for the parser to read with normal quoting rules. + /// + /// Once the number has been exceeded then the tokenizer returns the rest of the input as a + /// `TokenKind::Expand` token (see `Tokenizer::rest`), meaning that quoting rules do not apply + /// and none of the remaining text may be treated as a flag. + /// + /// If this is set to `None` then the entire command line is parsed with normal quoting and + /// flag rules. + /// + /// A good example use-case for this option is `:toggle-option` which sets `Some(1)`. + /// Everything up to the first positional argument is interpreted according to normal rules + /// and the rest of the input is parsed "raw". This allows `:toggle-option` to perform custom + /// parsing on the rest of the input - namely parsing complicated values as a JSON stream. + /// `:toggle-option` could accept a flag in the future. If so, the flag would need to come + /// before the first positional argument. + /// + /// Consider these lines for `:toggle-option` which sets `Some(1)`: + /// + /// * `:toggle foo` has one positional "foo" and no flags. + /// * `:toggle foo bar` has two positionals. Expansions for `bar` are evaluated but quotes + /// and anything that looks like a flag are treated literally. + /// * `:toggle foo --bar` has two positionals: `["foo", "--bar"]`. `--bar` is not considered + /// to be a flag because it comes after the first positional. + /// * `:toggle --bar foo` has one positional "foo" and one flag "--bar". + /// * `:toggle --bar foo --baz` has two positionals `["foo", "--baz"]` and one flag "--bar". + pub raw_after: Option, + /// A set of flags that a command may accept. + /// + /// See the `Flag` struct for more info. + pub flags: &'static [Flag], +} + +impl Signature { + // This allows defining signatures with the `..Signature::default()` shorthand. The + // `positionals` field should always be overwritten. + pub const fn default() -> Self { + Self { + positionals: (0, None), + raw_after: None, + flags: &[], + } + } + + /// The command handles all of its input as-is with no quoting or flags. + pub const fn raw() -> Self { + Self { + positionals: (1, Some(1)), + raw_after: Some(0), + flags: &[], + } + } + + fn check_positional_count(&self, actual: usize) -> Result<(), ParseArgsError<'static>> { + let (min, max) = self.positionals; + if min <= actual && max.unwrap_or(usize::MAX) >= actual { + Ok(()) + } else { + Err(ParseArgsError::WrongPositionalCount { min, max, actual }) + } + } +} + +#[derive(Debug, PartialEq, Eq)] +pub enum ParseArgsError<'a> { + WrongPositionalCount { + min: usize, + max: Option, + actual: usize, + }, + UnterminatedToken { + token: Token<'a>, + }, + DuplicatedFlag { + flag: &'static str, + }, + UnknownFlag { + text: Cow<'a, str>, + }, + FlagMissingArgument { + flag: &'static str, + }, + MissingExpansionDelimiter { + expansion: &'a str, + }, + UnknownExpansion { + kind: &'a str, + }, +} + +impl fmt::Display for ParseArgsError<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::WrongPositionalCount { min, max, actual } => { + f.write_str("expected ")?; + let maybe_plural = |n| if n == 1 { "" } else { "s" }; + match (min, max) { + (0, Some(0)) => f.write_str("no arguments")?, + (min, Some(max)) if min == max => { + f.write_fmt(format_args!("exactly {min} argument{}", maybe_plural(*min)))? + } + (min, _) if actual < min => f.write_fmt(format_args!( + "at least {min} argument{}", + maybe_plural(*min) + ))?, + (_, Some(max)) if actual > max => { + f.write_fmt(format_args!("at most {max} argument{}", maybe_plural(*max)))? + } + // `actual` must be less than `min` or greater than `max` for this type to be + // constructed. + _ => unreachable!(), + } + + f.write_fmt(format_args!(", got {actual}")) + } + Self::UnterminatedToken { token } => { + f.write_fmt(format_args!("unterminated token {}", token.content)) + } + Self::DuplicatedFlag { flag } => { + f.write_fmt(format_args!("flag '--{flag}' specified more than once")) + } + Self::UnknownFlag { text } => f.write_fmt(format_args!("unknown flag '{text}'")), + Self::FlagMissingArgument { flag } => { + f.write_fmt(format_args!("flag '--{flag}' missing an argument")) + } + Self::MissingExpansionDelimiter { expansion } => f.write_fmt(format_args!( + "missing a string delimiter after '%{expansion}'" + )), + Self::UnknownExpansion { kind } => { + f.write_fmt(format_args!("unknown expansion '{kind}'")) + } + } + } +} + +impl Error for ParseArgsError<'_> {} + +/// The kind of expansion to use on the token's content. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ExpansionKind { + /// Expand variables from the editor's state. + /// + /// For example `%{cursor_line}`. + Variable, + /// Treat the token contents as hexadecimal corresponding to a Unicode codepoint value. + /// + /// For example `%u{25CF}`. + Unicode, + /// Run the token's contents via the configured shell program. + /// + /// For example `%sh{echo hello}`. + Shell, +} + +impl ExpansionKind { + pub const VARIANTS: &'static [Self] = &[Self::Variable, Self::Unicode, Self::Shell]; + + pub const fn as_str(&self) -> &'static str { + match self { + Self::Variable => "", + Self::Unicode => "u", + Self::Shell => "sh", + } + } + + pub fn from_kind(name: &str) -> Option { + match name { + "" => Some(Self::Variable), + "u" => Some(Self::Unicode), + "sh" => Some(Self::Shell), + _ => None, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Quote { + Single, + Backtick, +} + +impl Quote { + pub const fn char(&self) -> char { + match self { + Self::Single => '\'', + Self::Backtick => '`', + } + } + + // Quotes can be escaped by doubling them: `'hello '' world'` becomes `hello ' world`. + pub const fn escape(&self) -> &'static str { + match self { + Self::Single => "''", + Self::Backtick => "``", + } + } +} + +/// The type of argument being written. +/// +/// The token kind decides how an argument in the command line will be expanded upon hitting +/// `` in command mode. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TokenKind { + /// Unquoted text. + /// + /// For example in `:echo hello world`, "hello" and "world" are raw tokens. + Unquoted, + /// Quoted text which is interpreted literally. + /// + /// The purpose of this kind is to avoid splitting arguments on whitespace. For example + /// `:open 'a b.txt'` will result in opening a file with a single argument `"a b.txt"`. + /// + /// Using expansions within single quotes or backticks will result in the expansion text + /// being shown literally. For example `:echo '%u{0020}'` will print `"%u{0020}"` to the + /// statusline. + Quoted(Quote), + /// Text within double quote delimiters (`"`). + /// + /// The inner text of a double quoted argument can be further expanded. For example + /// `:echo "line: #%{cursor_line}"` could print `"line: #1"` to the statusline. + Expand, + /// An expansion / "percent token". + /// + /// These take the form `%[]`. See `ExpansionKind`. + Expansion(ExpansionKind), + /// A token kind that exists for the sake of completion. + /// + /// In input like `%foo` this token contains the text `"%foo"`. The content start is the byte + /// after the percent token. + /// + /// When `Tokenizer` is passed `true` for its `validate` parameter this token cannot be + /// returned: inputs that would return this token get a validation error instead. + ExpansionKind, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Token<'a> { + pub kind: TokenKind, + /// The byte index into the input where the token's content starts. + /// + /// For quoted text this means the byte after the quote. For expansions this means the byte + /// after the opening delimiter. + pub content_start: usize, + /// The inner content of the token. + /// + /// Usually this content borrows from the input but an owned value may be used in cases of + /// escaping. On Unix systems a raw token like `a\ b` has the contents `"a b"`. + pub content: Cow<'a, str>, + /// Whether the token's opening delimiter is closed. + /// + /// For example a quote `"foo"` is closed but not `"foo` or an expansion `%sh{..}` is closed + /// but not `%sh{echo {}`. + pub is_terminated: bool, +} + +impl Token<'_> { + pub fn empty_at(content_start: usize) -> Self { + Self { + kind: TokenKind::Unquoted, + content_start, + content: Cow::Borrowed(""), + is_terminated: false, + } + } +} + +#[derive(Debug)] +pub struct Tokenizer<'a> { + input: &'a str, + /// Whether to return errors in the iterator for failed validations like unterminated strings + /// or expansions. When this is set to `false` the iterator will never return `Err`. + validate: bool, + /// The current byte index of the input being considered. + pos: usize, +} + +impl<'a> Tokenizer<'a> { + pub fn new(input: &'a str, validate: bool) -> Self { + Self { + input, + validate, + pos: 0, + } + } + + /// Returns the current byte index position of the parser in the input. + pub fn pos(&self) -> usize { + self.pos + } + + /// Returns the rest of the input as a single `TokenKind::Expand` token literally. + /// + /// Returns `None` if the tokenizer is already at the end of the input or advances the + /// tokenizer to the end of the input otherwise. Leading whitespace characters are skipped. + /// Quoting is not interpreted. + pub fn rest(&mut self) -> Option> { + self.skip_blanks(); + + if self.pos == self.input.len() { + return None; + } + + let content_start = self.pos; + self.pos = self.input.len(); + Some(Token { + kind: TokenKind::Expand, + content_start, + content: Cow::Borrowed(&self.input[content_start..]), + is_terminated: false, + }) + } + + fn byte(&self) -> Option { + self.input.as_bytes().get(self.pos).copied() + } + + fn peek_byte(&self) -> Option { + self.input.as_bytes().get(self.pos + 1).copied() + } + + fn prev_byte(&self) -> Option { + self.pos + .checked_sub(1) + .map(|idx| self.input.as_bytes()[idx]) + } + + fn skip_blanks(&mut self) { + while let Some(b' ' | b'\t') = self.byte() { + self.pos += 1; + } + } + + fn parse_unquoted(&mut self) -> Cow<'a, str> { + // Note that `String::new` starts with no allocation. We only allocate if we see a + // backslash escape (on Unix only). + let mut escaped = String::new(); + let mut start = self.pos; + + while let Some(byte) = self.byte() { + if matches!(byte, b' ' | b'\t') { + if cfg!(unix) && self.prev_byte() == Some(b'\\') { + // Push everything up to but not including the backslash and then this + // whitespace character. + escaped.push_str(&self.input[start..self.pos - 1]); + escaped.push(byte as char); + start = self.pos + 1; + } else if escaped.is_empty() { + return Cow::Borrowed(&self.input[start..self.pos]); + } else { + break; + } + } + + self.pos += 1; + } + + // Special case for a trailing backslash on Unix: exclude the backslash from the content. + // This improves the behavior of completions like `":open a\\"` (trailing backslash). + let end = if cfg!(unix) && self.prev_byte() == Some(b'\\') { + self.pos - 1 + } else { + self.pos + }; + + if escaped.is_empty() { + assert_eq!(self.pos, self.input.len()); + Cow::Borrowed(&self.input[start..end]) + } else { + escaped.push_str(&self.input[start..end]); + Cow::Owned(escaped) + } + } + + /// Parses a string quoted by the given grapheme cluster. + /// + /// The position of the tokenizer is asserted to be immediately after the quote grapheme + /// cluster. + fn parse_quoted(&mut self, quote: &str) -> (Cow<'a, str>, bool) { + assert!(self.input[..self.pos].ends_with(quote)); + + let mut escaped = String::new(); + + while let Some(offset) = self.input[self.pos..].find(quote) { + let idx = self.pos + offset; + + if self.input[idx + quote.len()..].starts_with(quote) { + // Treat two quotes in a row as an escape. + escaped.push_str(&self.input[self.pos..idx + quote.len()]); + self.pos += quote.len(); + } else { + // Otherwise this quote string is finished. + let quoted = if escaped.is_empty() { + Cow::Borrowed(&self.input[self.pos..idx]) + } else { + escaped.push_str(&self.input[self.pos..idx]); + Cow::Owned(escaped) + }; + // Advance past the closing quote. + self.pos = idx + quote.len(); + return (quoted, true); + } + + // Advance past the quote. + self.pos += offset + quote.len(); + } + + let quoted = if escaped.is_empty() { + Cow::Borrowed(&self.input[self.pos..]) + } else { + escaped.push_str(&self.input[self.pos..]); + Cow::Owned(escaped) + }; + self.pos = self.input.len(); + + (quoted, false) + } + + /// Parses the percent token expansion under the tokenizer's cursor. + /// + /// This function should only be called when the tokenizer's cursor is on a non-escaped + /// percent token. + pub fn parse_percent_token(&mut self) -> Option, ParseArgsError<'a>>> { + assert_eq!(self.byte(), Some(b'%')); + + self.pos += 1; + let kind_start = self.pos; + while self.byte().filter(|b| b.is_ascii_lowercase()).is_some() { + self.pos += 1; + } + let kind = &self.input[kind_start..self.pos]; + + let next_grapheme = self.input[self.pos..] + .graphemes(true) + .next() + .inspect(|grapheme| self.pos += grapheme.len()); + let opening_delimiter = match next_grapheme { + Some(" " | "\t") | None => { + return Some(if self.validate { + Err(ParseArgsError::MissingExpansionDelimiter { expansion: kind }) + } else { + Ok(Token { + kind: TokenKind::ExpansionKind, + content_start: kind_start, + content: Cow::Borrowed(kind), + is_terminated: false, + }) + }); + } + Some(g) => g, + }; + // The content start for expansions is the start of the content - after the opening + // delimiter grapheme. + let content_start = self.pos; + + let kind = match ExpansionKind::from_kind(kind) { + Some(kind) => TokenKind::Expansion(kind), + None if self.validate => { + return Some(Err(ParseArgsError::UnknownExpansion { kind })); + } + None => TokenKind::Expand, + }; + + const PAIRS: [(u8, u8); 4] = [(b'(', b')'), (b'[', b']'), (b'{', b'}'), (b'<', b'>')]; + + let (content, is_terminated) = if let Some((open, close)) = PAIRS + .iter() + .find(|(open, _)| opening_delimiter.as_bytes() == [*open]) + .copied() + { + self.parse_quoted_balanced(open, close) + } else { + self.parse_quoted(opening_delimiter) + }; + + let token = Token { + kind, + content_start, + content, + is_terminated, + }; + + if self.validate && !is_terminated { + return Some(Err(ParseArgsError::UnterminatedToken { token })); + } + + Some(Ok(token)) + } + + /// Parse the next string under the cursor given an open and closing pair. + /// + /// The open and closing pair are different ASCII characters. The cursor is asserted to be + /// immediately after the opening delimiter. + /// + /// This function parses with nesting support. `%sh{echo {hello}}` for example should consume + /// the entire input and not quit after the first '}' character is found. + fn parse_quoted_balanced(&mut self, open: u8, close: u8) -> (Cow<'a, str>, bool) { + assert_eq!(self.prev_byte(), Some(open)); + let start = self.pos; + let mut level = 1; + + while let Some(offset) = self.input[self.pos..].find([open as char, close as char]) { + let idx = self.pos + offset; + // Move past the delimiter. + self.pos = idx + 1; + + let byte = self.input.as_bytes()[idx]; + if byte == open { + level += 1; + } else if byte == close { + level -= 1; + if level == 0 { + break; + } + } else { + unreachable!() + } + } + + let is_terminated = level == 0; + let end = if is_terminated { + // Exclude the closing delimiter from the token's content. + self.pos - 1 + } else { + // When the token is not closed, advance to the end of the input. + self.pos = self.input.len(); + self.pos + }; + + (Cow::Borrowed(&self.input[start..end]), is_terminated) + } +} + +impl<'a> Iterator for Tokenizer<'a> { + type Item = Result, ParseArgsError<'a>>; + + fn next(&mut self) -> Option { + self.skip_blanks(); + + let byte = self.byte()?; + match byte { + b'"' | b'\'' | b'`' => { + self.pos += 1; + let content_start = self.pos; + let quote_bytes = &[byte]; + let quote_grapheme = + std::str::from_utf8(quote_bytes).expect("an ASCII byte is valid UTF-8"); + let (content, is_terminated) = self.parse_quoted(quote_grapheme); + let token = Token { + kind: match byte { + b'"' => TokenKind::Expand, + b'\'' => TokenKind::Quoted(Quote::Single), + b'`' => TokenKind::Quoted(Quote::Backtick), + _ => unreachable!(), + }, + content_start, + content, + is_terminated, + }; + + Some(if self.validate && !is_terminated { + Err(ParseArgsError::UnterminatedToken { token }) + } else { + Ok(token) + }) + } + b'%' => self.parse_percent_token(), + _ => { + let content_start = self.pos; + + // Allow backslash escaping on Unix for quotes or expansions + if cfg!(unix) + && byte == b'\\' + && matches!(self.peek_byte(), Some(b'"' | b'\'' | b'`' | b'%')) + { + self.pos += 1; + } + + Some(Ok(Token { + kind: TokenKind::Unquoted, + content_start, + content: self.parse_unquoted(), + is_terminated: false, + })) + } + } + } +} + +#[derive(Debug, Default, Clone, Copy)] +pub enum CompletionState { + #[default] + Positional, + Flag(Option), + FlagArgument(Flag), +} + +/// A set of arguments provided to a command on the command line. +/// +/// This struct contains both positional arguments - "regular" arguments that have an index - +/// and flags. For example the `:write` command could take a flag `--no-format` which would +/// prevent LSP or external formatting. In the command line `:write --no-format foo.rs` would +/// have one boolean flag `--no-format` and one positional argument `foo.rs`. +/// +/// The `Args` type can be treated essentially as a slice when accessing positional arguments. +/// `for arg in args` is valid as well as indexing into the positionals, like `&args[0]`. +/// Flags are excluded from this indexing or iterating no matter where they were specified in the +/// command line. For a command line like `:write --no-format foo.rs` for example, `&args[0]` +/// would correspond to the positional argument `foo.rs`. +/// +/// Typable commands declare a signature of minimum and (optionally) maximum positional arguments +/// to accept and this is checked before executing a command with `PromptEvent::Validate`, so +/// indexing into `Args` within a command's accepted range is safe. +#[derive(Debug)] +pub struct Args<'a> { + signature: Signature, + /// Whether to validate the arguments. + /// See the `ParseArgsError` type for the validations. + validate: bool, + /// Whether args pushed with `Self::push` should be treated as positionals even if they + /// start with '-'. + only_positionals: bool, + state: CompletionState, + positionals: Vec>, + flags: HashMap<&'static str, Cow<'a, str>>, +} + +impl Default for Args<'_> { + fn default() -> Self { + Self { + signature: Signature::default(), + validate: Default::default(), + only_positionals: Default::default(), + state: CompletionState::default(), + positionals: Default::default(), + flags: Default::default(), + } + } +} + +impl<'a> Args<'a> { + pub fn new(signature: Signature, validate: bool) -> Self { + Self { + signature, + validate, + only_positionals: false, + positionals: Vec::new(), + flags: HashMap::new(), + state: CompletionState::default(), + } + } + + /// Reads the next token out of the given parser. + /// + /// If the command's signature sets a maximum number of positionals (via `raw_after`) then + /// the token may contain the rest of the parser's input. + pub fn read_token<'p>( + &mut self, + parser: &mut Tokenizer<'p>, + ) -> Result>, ParseArgsError<'p>> { + if self + .signature + .raw_after + .is_some_and(|max| self.len() >= max as usize) + { + self.only_positionals = true; + Ok(parser.rest()) + } else { + parser.next().transpose() + } + } + + /// Parses the given command line according to a command's signature. + /// + /// The `try_map_fn` function can be used to try changing each token before it is considered + /// as an argument - this is used for variable expansion. + pub fn parse( + line: &'a str, + signature: Signature, + validate: bool, + mut try_map_fn: M, + ) -> Result> + where + // Note: this is a `FnMut` in case we decide to allow caching expansions in the future. + // The `mut` is not currently used. + M: FnMut(Token<'a>) -> Result, Box>, + { + let mut tokenizer = Tokenizer::new(line, validate); + let mut args = Self::new(signature, validate); + + while let Some(token) = args.read_token(&mut tokenizer)? { + let arg = try_map_fn(token)?; + args.push(arg)?; + } + + args.finish()?; + + Ok(args) + } + + /// Adds the given argument token. + /// + /// Once all arguments have been added, `Self::finish` should be called to perform any + /// closing validations. + pub fn push(&mut self, arg: Cow<'a, str>) -> Result<(), ParseArgsError<'a>> { + if !self.only_positionals && arg == "--" { + // "--" marks the end of flags, everything after is a positional even if it starts + // with '-'. + self.only_positionals = true; + self.state = CompletionState::Flag(None); + } else if let Some(flag) = self.flag_awaiting_argument() { + // If the last token was a flag which accepts an argument, treat this token as a flag + // argument. + self.flags.insert(flag.name, arg); + self.state = CompletionState::FlagArgument(flag); + } else if !self.only_positionals && arg.starts_with('-') { + // If the token starts with '-' and we are not only accepting positional arguments, + // treat this token as a flag. + let flag = if let Some(longhand) = arg.strip_prefix("--") { + self.signature + .flags + .iter() + .find(|flag| flag.name == longhand) + } else { + let shorthand = arg.strip_prefix('-').unwrap(); + self.signature.flags.iter().find(|flag| { + flag.alias.is_some_and(|ch| { + shorthand.starts_with(ch) && ch.len_utf8() == shorthand.len() + }) + }) + }; + + let Some(flag) = flag else { + if self.validate { + return Err(ParseArgsError::UnknownFlag { text: arg }); + } + + self.positionals.push(arg); + self.state = CompletionState::Flag(None); + return Ok(()); + }; + + if self.validate && self.flags.contains_key(flag.name) { + return Err(ParseArgsError::DuplicatedFlag { flag: flag.name }); + } + + self.flags.insert(flag.name, Cow::Borrowed("")); + self.state = CompletionState::Flag(Some(*flag)); + } else { + // Otherwise this token is a positional argument. + self.positionals.push(arg); + self.state = CompletionState::Positional; + } + + Ok(()) + } + + /// Performs any validations that must be done after the input args are finished being pushed + /// with `Self::push`. + fn finish(&self) -> Result<(), ParseArgsError<'a>> { + if !self.validate { + return Ok(()); + }; + + if let Some(flag) = self.flag_awaiting_argument() { + return Err(ParseArgsError::FlagMissingArgument { flag: flag.name }); + } + self.signature + .check_positional_count(self.positionals.len())?; + + Ok(()) + } + + fn flag_awaiting_argument(&self) -> Option { + match self.state { + CompletionState::Flag(flag) => flag.filter(|f| f.completions.is_some()), + _ => None, + } + } + + /// Returns the kind of argument the last token is considered to be. + /// + /// For example if the last argument in the command line is `--foo` then the argument may be + /// considered to be a flag. + pub fn completion_state(&self) -> CompletionState { + self.state + } + + /// Returns the number of positionals supplied in the input. + /// + /// This number does not account for any flags passed in the input. + pub fn len(&self) -> usize { + self.positionals.len() + } + + /// Checks whether the arguments contain no positionals. + /// + /// Note that this function returns `true` if there are no positional arguments even if the + /// input contained flags. + pub fn is_empty(&self) -> bool { + self.positionals.is_empty() + } + + /// Gets the first positional argument, if one exists. + pub fn first(&'a self) -> Option<&'a str> { + self.positionals.first().map(AsRef::as_ref) + } + + /// Gets the positional argument at the given index, if one exists. + pub fn get(&'a self, index: usize) -> Option<&'a str> { + self.positionals.get(index).map(AsRef::as_ref) + } + + /// Flattens all positional arguments together with the given separator between each + /// positional. + pub fn join(&self, sep: &str) -> String { + self.positionals.join(sep) + } + + /// Gets the value associated with a flag's long name if the flag was provided. + /// + /// This function should be preferred over [Self::has_flag] when the flag accepts an argument. + pub fn get_flag(&'a self, name: &'static str) -> Option<&'a str> { + self.flags.get(name).map(AsRef::as_ref) + } + + /// Checks if a flag was provided in the arguments. + /// + /// This function should be preferred over [Self::get_flag] for boolean flags - flags that + /// either are present or not. + pub fn has_flag(&self, name: &'static str) -> bool { + self.flags.contains_key(name) + } +} + +// `arg[n]` +impl ops::Index for Args<'_> { + type Output = str; + + fn index(&self, index: usize) -> &Self::Output { + self.positionals[index].as_ref() + } +} + +// `for arg in args { .. }` +impl<'a> IntoIterator for Args<'a> { + type Item = Cow<'a, str>; + type IntoIter = vec::IntoIter>; + + fn into_iter(self) -> Self::IntoIter { + self.positionals.into_iter() + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[track_caller] + fn assert_tokens(input: &str, expected: &[&str]) { + let actual: Vec<_> = Tokenizer::new(input, true) + .map(|arg| arg.unwrap().content) + .collect(); + let actual: Vec<_> = actual.iter().map(|c| c.as_ref()).collect(); + + assert_eq!(actual.as_slice(), expected); + } + + #[track_caller] + fn assert_incomplete_tokens(input: &str, expected: &[&str]) { + let actual: Vec<_> = Tokenizer::new(input, false) + .map(|arg| arg.unwrap().content) + .collect(); + let actual: Vec<_> = actual.iter().map(|c| c.as_ref()).collect(); + + assert_eq!(actual.as_slice(), expected); + } + + #[test] + fn tokenize_unquoted() { + assert_tokens("", &[]); + assert_tokens("hello", &["hello"]); + assert_tokens("hello world", &["hello", "world"]); + // Any amount of whitespace is considered a separator. + assert_tokens("hello\t \tworld", &["hello", "world"]); + } + + // This escaping behavior is specific to Unix systems. + #[cfg(unix)] + #[test] + fn tokenize_backslash_unix() { + assert_tokens(r#"hello\ world"#, &["hello world"]); + assert_tokens(r#"one\ two three"#, &["one two", "three"]); + assert_tokens(r#"one two\ three"#, &["one", "two three"]); + // Trailing backslash is ignored - this improves completions. + assert_tokens(r#"hello\"#, &["hello"]); + // The backslash at the start of the double quote makes the quote be treated as raw. + // For the backslash before the ending quote the token is already considered raw so the + // backslash and quote are treated literally. + assert_tokens( + r#"echo \"hello world\""#, + &["echo", r#""hello"#, r#"world\""#], + ); + } + + #[test] + fn tokenize_backslash() { + assert_tokens(r#"\n"#, &["\\n"]); + assert_tokens(r#"'\'"#, &["\\"]); + } + + #[test] + fn tokenize_quoting() { + // Using a quote character twice escapes it. + assert_tokens(r#"''"#, &[""]); + assert_tokens(r#""""#, &[""]); + assert_tokens(r#"``"#, &[""]); + assert_tokens(r#"echo """#, &["echo", ""]); + + assert_tokens(r#"'hello'"#, &["hello"]); + assert_tokens(r#"'hello world'"#, &["hello world"]); + + assert_tokens(r#""hello "" world""#, &["hello \" world"]); + } + + #[test] + fn tokenize_percent() { + // Pair delimiters: + assert_tokens(r#"echo %{hello world}"#, &["echo", "hello world"]); + assert_tokens(r#"echo %[hello world]"#, &["echo", "hello world"]); + assert_tokens(r#"echo %(hello world)"#, &["echo", "hello world"]); + assert_tokens(r#"echo %"#, &["echo", "hello world"]); + // Any character can be used as a delimiter. + assert_tokens(r#"echo %|hello world|"#, &["echo", "hello world"]); + // Yes, even this crazy stuff. Multi-codepoint grapheme clusters are supported too. + assert_tokens(r#"echo %🏴‍☠️hello world🏴‍☠️"#, &["echo", "hello world"]); + // When invoking a command, double precents can be used within a string as an escape for + // the percent. This is done in the expansion code though, not in the parser here. + assert_tokens(r#"echo "%%hello world""#, &["echo", "%%hello world"]); + // Different kinds of quotes nested: + assert_tokens( + r#"echo "%sh{echo 'hello world'}""#, + &["echo", r#"%sh{echo 'hello world'}"#], + ); + // Nesting of the expansion delimiter: + assert_tokens(r#"echo %{hello {x} world}"#, &["echo", "hello {x} world"]); + assert_tokens( + r#"echo %{hello {{😎}} world}"#, + &["echo", "hello {{😎}} world"], + ); + + // Balanced nesting: + assert_tokens( + r#"echo %{hello {}} world}"#, + &["echo", "hello {}", "world}"], + ); + + // Recursive expansions: + assert_tokens( + r#"echo %sh{echo "%{cursor_line}"}"#, + &["echo", r#"echo "%{cursor_line}""#], + ); + // Completion should provide variable names here. (Unbalanced nesting) + assert_incomplete_tokens(r#"echo %sh{echo "%{c"#, &["echo", r#"echo "%{c"#]); + assert_incomplete_tokens(r#"echo %{hello {{} world}"#, &["echo", "hello {{} world}"]); + } + + fn parse_signature<'a>( + input: &'a str, + signature: Signature, + ) -> Result, Box> { + Args::parse(input, signature, true, |token| Ok(token.content)) + } + + #[test] + fn signature_validation_positionals() { + let signature = Signature { + positionals: (2, Some(3)), + ..Signature::default() + }; + + assert!(parse_signature("hello world", signature).is_ok()); + assert!(parse_signature("foo bar baz", signature).is_ok()); + assert!(parse_signature(r#"a "b c" d"#, signature).is_ok()); + + assert!(parse_signature("hello", signature).is_err()); + assert!(parse_signature("foo bar baz quiz", signature).is_err()); + + let signature = Signature { + positionals: (1, None), + ..Signature::default() + }; + + assert!(parse_signature("a", signature).is_ok()); + assert!(parse_signature("a b", signature).is_ok()); + assert!(parse_signature(r#"a "b c" d"#, signature).is_ok()); + + assert!(parse_signature("", signature).is_err()); + } + + #[test] + fn flags() { + let signature = Signature { + positionals: (1, Some(2)), + flags: &[ + Flag { + name: "foo", + alias: Some('f'), + doc: "", + completions: None, + }, + Flag { + name: "bar", + alias: Some('b'), + doc: "", + completions: Some(&[]), + }, + ], + ..Signature::default() + }; + + let args = parse_signature("hello", signature).unwrap(); + assert_eq!(args.len(), 1); + assert_eq!(&args[0], "hello"); + assert!(!args.has_flag("foo")); + assert!(args.get_flag("bar").is_none()); + + let args = parse_signature("--bar abcd hello world --foo", signature).unwrap(); + assert_eq!(args.len(), 2); + assert_eq!(&args[0], "hello"); + assert_eq!(&args[1], "world"); + assert!(args.has_flag("foo")); + assert_eq!(args.get_flag("bar"), Some("abcd")); + + let args = parse_signature("hello -f -b abcd world", signature).unwrap(); + assert_eq!(args.len(), 2); + assert_eq!(&args[0], "hello"); + assert_eq!(&args[1], "world"); + assert!(args.has_flag("foo")); + assert_eq!(args.get_flag("bar"), Some("abcd")); + + // The signature requires at least one positional. + assert!(parse_signature("--foo", signature).is_err()); + // And at most two. + assert!(parse_signature("abc --bar baz def efg", signature).is_err()); + + let args = parse_signature(r#"abc -b "xyz 123" def"#, signature).unwrap(); + assert_eq!(args.len(), 2); + assert_eq!(&args[0], "abc"); + assert_eq!(&args[1], "def"); + assert_eq!(args.get_flag("bar"), Some("xyz 123")); + + // Unknown flags are validation errors. + assert!(parse_signature(r#"foo --quiz"#, signature).is_err()); + // Duplicated flags are parsing errors. + assert!(parse_signature(r#"--foo bar --foo"#, signature).is_err()); + assert!(parse_signature(r#"-f bar --foo"#, signature).is_err()); + + // "--" can be used to mark the end of flags. Everything after is considered a positional. + let args = parse_signature(r#"hello --bar baz -- --foo"#, signature).unwrap(); + assert_eq!(args.len(), 2); + assert_eq!(&args[0], "hello"); + assert_eq!(&args[1], "--foo"); + assert_eq!(args.get_flag("bar"), Some("baz")); + assert!(!args.has_flag("foo")); + } + + #[test] + fn raw_after() { + let signature = Signature::raw(); + + // All quoting and escaping is treated literally in raw mode. + let args = parse_signature(r#"'\'"#, signature).unwrap(); + assert_eq!(args.len(), 1); + assert_eq!(&args[0], "'\\'"); + let args = parse_signature(r#"\''"#, signature).unwrap(); + assert_eq!(args.len(), 1); + assert_eq!(&args[0], "\\''"); + + // Leading space is trimmed. + let args = parse_signature(r#" %sh{foo}"#, signature).unwrap(); + assert_eq!(args.len(), 1); + assert_eq!(&args[0], "%sh{foo}"); + + let signature = Signature { + positionals: (1, Some(2)), + raw_after: Some(1), + ..Signature::default() + }; + + let args = parse_signature("foo", signature).unwrap(); + assert_eq!(args.len(), 1); + assert_eq!(&args[0], "foo"); + + // "--bar" is treated as a positional. + let args = parse_signature("foo --bar", signature).unwrap(); + assert_eq!(args.len(), 2); + assert_eq!(&args[0], "foo"); + assert_eq!(&args[1], "--bar"); + + let args = parse_signature("abc def ghi", signature).unwrap(); + assert_eq!(args.len(), 2); + assert_eq!(&args[0], "abc"); + assert_eq!(&args[1], "def ghi"); + + let args = parse_signature("rulers [20, 30]", signature).unwrap(); + assert_eq!(args.len(), 2); + assert_eq!(&args[0], "rulers"); + assert_eq!(&args[1], "[20, 30]"); + + let args = + parse_signature(r#"gutters ["diff"] ["diff", "diagnostics"]"#, signature).unwrap(); + assert_eq!(args.len(), 2); + assert_eq!(&args[0], "gutters"); + assert_eq!(&args[1], r#"["diff"] ["diff", "diagnostics"]"#); + } +} diff --git a/helix-core/src/lib.rs b/helix-core/src/lib.rs index 2bf75f6906d3f..946cc2050b043 100644 --- a/helix-core/src/lib.rs +++ b/helix-core/src/lib.rs @@ -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; diff --git a/helix-core/src/shellwords.rs b/helix-core/src/shellwords.rs deleted file mode 100644 index 9d873c366c994..0000000000000 --- a/helix-core/src/shellwords.rs +++ /dev/null @@ -1,350 +0,0 @@ -use std::borrow::Cow; - -/// Auto escape for shellwords usage. -pub fn escape(input: Cow) -> Cow { - 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>, - /// 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𒀀"] - ); - } -} diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index 755a7dc077bbb..09f253274438c 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -17,7 +17,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, RevRopeGraphemes}, @@ -30,7 +30,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, @@ -55,7 +55,6 @@ use insert::*; use movement::Movement; use crate::{ - args, compositor::{self, Component, Compositor}, filter_picker_entry, job::Callback, @@ -207,7 +206,7 @@ use helix_view::{align_view, Align}; pub enum MappableCommand { Typable { name: String, - args: Vec, + args: String, doc: String, }, Static { @@ -242,16 +241,19 @@ 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) { + 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), @@ -621,19 +623,14 @@ impl std::str::FromStr for MappableCommand { fn from_str(s: &str) -> Result { 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::>(); + let (name, args, _) = command_line::split(suffix); + ensure!(!name.is_empty(), "Expected typable command name"); typed::TYPABLE_COMMAND_MAP .get(name) .map(|cmd| MappableCommand::Typable { name: cmd.name.to_owned(), doc: format!(":{} {:?}", cmd.name, args), - args, + args: args.to_string(), }) .ok_or_else(|| anyhow!("No TypableCommand named '{}'", s)) } else if let Some(suffix) = s.strip_prefix('@') { @@ -3253,7 +3250,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(), }), ); diff --git a/helix-term/src/commands/typed.rs b/helix-term/src/commands/typed.rs index 4a2546d7c8d8c..72279459ae735 100644 --- a/helix-term/src/commands/typed.rs +++ b/helix-term/src/commands/typed.rs @@ -1,17 +1,19 @@ use std::fmt::Write; use std::io::BufReader; -use std::ops::Deref; +use std::ops::{self, Deref}; use crate::job::Job; use super::*; +use helix_core::command_line::{Args, Flag, Signature}; use helix_core::fuzzy::fuzzy_match; use helix_core::indent::MAX_INDENT; -use helix_core::{line_ending, shellwords::Shellwords}; +use helix_core::line_ending; use helix_stdx::path::home_dir; use helix_view::document::{read_to_string, DEFAULT_LANGUAGE_NAME}; use helix_view::editor::{CloseError, ConfigEvent}; +use helix_view::expansion; use serde_json::Value; use ui::completers::{self, Completer}; @@ -21,22 +23,23 @@ pub struct TypableCommand { pub aliases: &'static [&'static str], pub doc: &'static str, // params, flags, helper, completer - pub fun: fn(&mut compositor::Context, &[Cow], PromptEvent) -> anyhow::Result<()>, + pub fun: fn(&mut compositor::Context, Args, PromptEvent) -> anyhow::Result<()>, /// What completion methods, if any, does this command have? - pub signature: CommandSignature, + pub completer: CommandCompleter, + pub signature: Signature, } impl TypableCommand { fn completer_for_argument_number(&self, n: usize) -> &Completer { - match self.signature.positional_args.get(n) { + match self.completer.positional_args.get(n) { Some(completer) => completer, - _ => &self.signature.var_args, + _ => &self.completer.var_args, } } } #[derive(Clone)] -pub struct CommandSignature { +pub struct CommandCompleter { // Arguments with specific completion methods based on their position. positional_args: &'static [Completer], @@ -44,7 +47,7 @@ pub struct CommandSignature { var_args: Completer, } -impl CommandSignature { +impl CommandCompleter { const fn none() -> Self { Self { positional_args: &[], @@ -67,15 +70,13 @@ impl CommandSignature { } } -fn quit(cx: &mut compositor::Context, args: &[Cow], event: PromptEvent) -> anyhow::Result<()> { +fn quit(cx: &mut compositor::Context, _args: Args, event: PromptEvent) -> anyhow::Result<()> { log::debug!("quitting..."); if event != PromptEvent::Validate { return Ok(()); } - ensure!(args.is_empty(), ":quit takes no arguments"); - // last view and we have unsaved changes if cx.editor.tree.views().count() == 1 { buffers_remaining_impl(cx.editor)? @@ -87,31 +88,24 @@ fn quit(cx: &mut compositor::Context, args: &[Cow], event: PromptEvent) -> Ok(()) } -fn force_quit( - cx: &mut compositor::Context, - args: &[Cow], - event: PromptEvent, -) -> anyhow::Result<()> { +fn force_quit(cx: &mut compositor::Context, _args: Args, event: PromptEvent) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } - ensure!(args.is_empty(), ":quit! takes no arguments"); - cx.block_try_flush_writes()?; cx.editor.close(view!(cx.editor).id); Ok(()) } -fn open(cx: &mut compositor::Context, args: &[Cow], event: PromptEvent) -> anyhow::Result<()> { +fn open(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } - ensure!(!args.is_empty(), "wrong argument count"); for arg in args { - let (path, pos) = args::parse_file(arg); + let (path, pos) = crate::args::parse_file(&arg); let path = helix_stdx::path::expand_tilde(path); // If the path is a directory, open a file picker on that directory and update the status // message @@ -175,7 +169,7 @@ fn buffer_close_by_ids_impl( Ok(()) } -fn buffer_gather_paths_impl(editor: &mut Editor, args: &[Cow]) -> Vec { +fn buffer_gather_paths_impl(editor: &mut Editor, args: Args) -> Vec { // No arguments implies current document if args.is_empty() { let doc_id = view!(editor).doc; @@ -212,7 +206,7 @@ fn buffer_gather_paths_impl(editor: &mut Editor, args: &[Cow]) -> Vec], + args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -225,7 +219,7 @@ fn buffer_close( fn force_buffer_close( cx: &mut compositor::Context, - args: &[Cow], + args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -247,7 +241,7 @@ fn buffer_gather_others_impl(editor: &mut Editor) -> Vec { fn buffer_close_others( cx: &mut compositor::Context, - _args: &[Cow], + _args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -260,7 +254,7 @@ fn buffer_close_others( fn force_buffer_close_others( cx: &mut compositor::Context, - _args: &[Cow], + _args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -277,7 +271,7 @@ fn buffer_gather_all_impl(editor: &mut Editor) -> Vec { fn buffer_close_all( cx: &mut compositor::Context, - _args: &[Cow], + _args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -290,7 +284,7 @@ fn buffer_close_all( fn force_buffer_close_all( cx: &mut compositor::Context, - _args: &[Cow], + _args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -303,7 +297,7 @@ fn force_buffer_close_all( fn buffer_next( cx: &mut compositor::Context, - _args: &[Cow], + _args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -316,7 +310,7 @@ fn buffer_next( fn buffer_previous( cx: &mut compositor::Context, - _args: &[Cow], + _args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -327,15 +321,10 @@ fn buffer_previous( Ok(()) } -fn write_impl( - cx: &mut compositor::Context, - path: Option<&Cow>, - force: bool, -) -> anyhow::Result<()> { +fn write_impl(cx: &mut compositor::Context, path: Option<&str>, force: bool) -> anyhow::Result<()> { let config = cx.editor.config(); let jobs = &mut cx.jobs; let (view, doc) = current!(cx.editor); - let path = path.map(AsRef::as_ref); if config.insert_final_newline { insert_final_newline(doc, view.id); @@ -377,11 +366,7 @@ fn insert_final_newline(doc: &mut Document, view_id: ViewId) { } } -fn write( - cx: &mut compositor::Context, - args: &[Cow], - event: PromptEvent, -) -> anyhow::Result<()> { +fn write(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } @@ -389,11 +374,7 @@ fn write( write_impl(cx, args.first(), false) } -fn force_write( - cx: &mut compositor::Context, - args: &[Cow], - event: PromptEvent, -) -> anyhow::Result<()> { +fn force_write(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } @@ -403,7 +384,7 @@ fn force_write( fn write_buffer_close( cx: &mut compositor::Context, - args: &[Cow], + args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -418,7 +399,7 @@ fn write_buffer_close( fn force_write_buffer_close( cx: &mut compositor::Context, - args: &[Cow], + args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -431,11 +412,7 @@ fn force_write_buffer_close( buffer_close_by_ids_impl(cx, &document_ids, false) } -fn new_file( - cx: &mut compositor::Context, - _args: &[Cow], - event: PromptEvent, -) -> anyhow::Result<()> { +fn new_file(cx: &mut compositor::Context, _args: Args, event: PromptEvent) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } @@ -445,11 +422,7 @@ fn new_file( Ok(()) } -fn format( - cx: &mut compositor::Context, - _args: &[Cow], - event: PromptEvent, -) -> anyhow::Result<()> { +fn format(cx: &mut compositor::Context, _args: Args, event: PromptEvent) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } @@ -466,7 +439,7 @@ fn format( fn set_indent_style( cx: &mut compositor::Context, - args: &[Cow], + args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -489,7 +462,7 @@ fn set_indent_style( // Attempt to parse argument as an indent style. let style = match args.first() { Some(arg) if "tabs".starts_with(&arg.to_lowercase()) => Some(Tabs), - Some(Cow::Borrowed("0")) => Some(Tabs), + Some("0") => Some(Tabs), Some(arg) => arg .parse::() .ok() @@ -508,7 +481,7 @@ fn set_indent_style( /// Sets or reports the current document's line ending setting. fn set_line_ending( cx: &mut compositor::Context, - args: &[Cow], + args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -578,11 +551,7 @@ fn set_line_ending( Ok(()) } -fn earlier( - cx: &mut compositor::Context, - args: &[Cow], - event: PromptEvent, -) -> anyhow::Result<()> { +fn earlier(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } @@ -598,11 +567,7 @@ fn earlier( Ok(()) } -fn later( - cx: &mut compositor::Context, - args: &[Cow], - event: PromptEvent, -) -> anyhow::Result<()> { +fn later(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } @@ -617,23 +582,19 @@ fn later( Ok(()) } -fn write_quit( - cx: &mut compositor::Context, - args: &[Cow], - event: PromptEvent, -) -> anyhow::Result<()> { +fn write_quit(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } write_impl(cx, args.first(), false)?; cx.block_try_flush_writes()?; - quit(cx, &[], event) + quit(cx, Args::default(), event) } fn force_write_quit( cx: &mut compositor::Context, - args: &[Cow], + args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -642,7 +603,7 @@ fn force_write_quit( write_impl(cx, args.first(), true)?; cx.block_try_flush_writes()?; - force_quit(cx, &[], event) + force_quit(cx, Args::default(), event) } /// Results in an error if there are modified buffers remaining and sets editor @@ -749,11 +710,7 @@ pub fn write_all_impl( Ok(()) } -fn write_all( - cx: &mut compositor::Context, - _args: &[Cow], - event: PromptEvent, -) -> anyhow::Result<()> { +fn write_all(cx: &mut compositor::Context, _args: Args, event: PromptEvent) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } @@ -763,7 +720,7 @@ fn write_all( fn force_write_all( cx: &mut compositor::Context, - _args: &[Cow], + _args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -775,7 +732,7 @@ fn force_write_all( fn write_all_quit( cx: &mut compositor::Context, - _args: &[Cow], + _args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -787,7 +744,7 @@ fn write_all_quit( fn force_write_all_quit( cx: &mut compositor::Context, - _args: &[Cow], + _args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -812,11 +769,7 @@ fn quit_all_impl(cx: &mut compositor::Context, force: bool) -> anyhow::Result<() Ok(()) } -fn quit_all( - cx: &mut compositor::Context, - _args: &[Cow], - event: PromptEvent, -) -> anyhow::Result<()> { +fn quit_all(cx: &mut compositor::Context, _args: Args, event: PromptEvent) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } @@ -826,7 +779,7 @@ fn quit_all( fn force_quit_all( cx: &mut compositor::Context, - _args: &[Cow], + _args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -836,11 +789,7 @@ fn force_quit_all( quit_all_impl(cx, true) } -fn cquit( - cx: &mut compositor::Context, - args: &[Cow], - event: PromptEvent, -) -> anyhow::Result<()> { +fn cquit(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } @@ -854,11 +803,7 @@ fn cquit( quit_all_impl(cx, false) } -fn force_cquit( - cx: &mut compositor::Context, - args: &[Cow], - event: PromptEvent, -) -> anyhow::Result<()> { +fn force_cquit(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } @@ -872,11 +817,7 @@ fn force_cquit( quit_all_impl(cx, true) } -fn theme( - cx: &mut compositor::Context, - args: &[Cow], - event: PromptEvent, -) -> anyhow::Result<()> { +fn theme(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> anyhow::Result<()> { let true_color = cx.editor.config.load().true_color || crate::true_color(); match event { PromptEvent::Abort => { @@ -919,7 +860,7 @@ fn theme( fn yank_main_selection_to_clipboard( cx: &mut compositor::Context, - _args: &[Cow], + _args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -930,17 +871,11 @@ fn yank_main_selection_to_clipboard( Ok(()) } -fn yank_joined( - cx: &mut compositor::Context, - args: &[Cow], - event: PromptEvent, -) -> anyhow::Result<()> { +fn yank_joined(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } - ensure!(args.len() <= 1, ":yank-join takes at most 1 argument"); - let doc = doc!(cx.editor); let default_sep = Cow::Borrowed(doc.line_ending.as_str()); let separator = args.first().unwrap_or(&default_sep); @@ -951,7 +886,7 @@ fn yank_joined( fn yank_joined_to_clipboard( cx: &mut compositor::Context, - args: &[Cow], + args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -967,7 +902,7 @@ fn yank_joined_to_clipboard( fn yank_main_selection_to_primary_clipboard( cx: &mut compositor::Context, - _args: &[Cow], + _args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -980,7 +915,7 @@ fn yank_main_selection_to_primary_clipboard( fn yank_joined_to_primary_clipboard( cx: &mut compositor::Context, - args: &[Cow], + args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -996,7 +931,7 @@ fn yank_joined_to_primary_clipboard( fn paste_clipboard_after( cx: &mut compositor::Context, - _args: &[Cow], + _args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -1009,7 +944,7 @@ fn paste_clipboard_after( fn paste_clipboard_before( cx: &mut compositor::Context, - _args: &[Cow], + _args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -1022,7 +957,7 @@ fn paste_clipboard_before( fn paste_primary_clipboard_after( cx: &mut compositor::Context, - _args: &[Cow], + _args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -1035,7 +970,7 @@ fn paste_primary_clipboard_after( fn paste_primary_clipboard_before( cx: &mut compositor::Context, - _args: &[Cow], + _args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -1048,7 +983,7 @@ fn paste_primary_clipboard_before( fn replace_selections_with_clipboard( cx: &mut compositor::Context, - _args: &[Cow], + _args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -1061,7 +996,7 @@ fn replace_selections_with_clipboard( fn replace_selections_with_primary_clipboard( cx: &mut compositor::Context, - _args: &[Cow], + _args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -1074,7 +1009,7 @@ fn replace_selections_with_primary_clipboard( fn show_clipboard_provider( cx: &mut compositor::Context, - _args: &[Cow], + _args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -1088,7 +1023,7 @@ fn show_clipboard_provider( fn change_current_directory( cx: &mut compositor::Context, - args: &[Cow], + args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -1122,7 +1057,7 @@ fn change_current_directory( fn show_current_directory( cx: &mut compositor::Context, - _args: &[Cow], + _args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -1143,7 +1078,7 @@ fn show_current_directory( /// Sets the [`Document`]'s encoding.. fn set_encoding( cx: &mut compositor::Context, - args: &[Cow], + args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -1163,7 +1098,7 @@ fn set_encoding( /// Shows info about the character under the primary cursor. fn get_character_info( cx: &mut compositor::Context, - _args: &[Cow], + _args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -1286,11 +1221,7 @@ fn get_character_info( } /// Reload the [`Document`] from its source file. -fn reload( - cx: &mut compositor::Context, - _args: &[Cow], - event: PromptEvent, -) -> anyhow::Result<()> { +fn reload(cx: &mut compositor::Context, _args: Args, event: PromptEvent) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } @@ -1309,11 +1240,7 @@ fn reload( Ok(()) } -fn reload_all( - cx: &mut compositor::Context, - _args: &[Cow], - event: PromptEvent, -) -> anyhow::Result<()> { +fn reload_all(cx: &mut compositor::Context, _args: Args, event: PromptEvent) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } @@ -1369,11 +1296,7 @@ fn reload_all( } /// Update the [`Document`] if it has been modified. -fn update( - cx: &mut compositor::Context, - args: &[Cow], - event: PromptEvent, -) -> anyhow::Result<()> { +fn update(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } @@ -1388,7 +1311,7 @@ fn update( fn lsp_workspace_command( cx: &mut compositor::Context, - args: &[Cow], + args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -1478,7 +1401,7 @@ fn lsp_workspace_command( fn lsp_restart( cx: &mut compositor::Context, - _args: &[Cow], + _args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -1524,11 +1447,7 @@ fn lsp_restart( Ok(()) } -fn lsp_stop( - cx: &mut compositor::Context, - _args: &[Cow], - event: PromptEvent, -) -> anyhow::Result<()> { +fn lsp_stop(cx: &mut compositor::Context, _args: Args, event: PromptEvent) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } @@ -1555,7 +1474,7 @@ fn lsp_stop( fn tree_sitter_scopes( cx: &mut compositor::Context, - _args: &[Cow], + _args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -1588,7 +1507,7 @@ fn tree_sitter_scopes( fn tree_sitter_highlight_name( cx: &mut compositor::Context, - _args: &[Cow], + _args: Args, event: PromptEvent, ) -> anyhow::Result<()> { fn find_highlight_at_cursor( @@ -1661,11 +1580,7 @@ fn tree_sitter_highlight_name( Ok(()) } -fn vsplit( - cx: &mut compositor::Context, - args: &[Cow], - event: PromptEvent, -) -> anyhow::Result<()> { +fn vsplit(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } @@ -1682,11 +1597,7 @@ fn vsplit( Ok(()) } -fn hsplit( - cx: &mut compositor::Context, - args: &[Cow], - event: PromptEvent, -) -> anyhow::Result<()> { +fn hsplit(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } @@ -1703,11 +1614,7 @@ fn hsplit( Ok(()) } -fn vsplit_new( - cx: &mut compositor::Context, - _args: &[Cow], - event: PromptEvent, -) -> anyhow::Result<()> { +fn vsplit_new(cx: &mut compositor::Context, _args: Args, event: PromptEvent) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } @@ -1717,11 +1624,7 @@ fn vsplit_new( Ok(()) } -fn hsplit_new( - cx: &mut compositor::Context, - _args: &[Cow], - event: PromptEvent, -) -> anyhow::Result<()> { +fn hsplit_new(cx: &mut compositor::Context, _args: Args, event: PromptEvent) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } @@ -1731,11 +1634,7 @@ fn hsplit_new( Ok(()) } -fn debug_eval( - cx: &mut compositor::Context, - args: &[Cow], - event: PromptEvent, -) -> anyhow::Result<()> { +fn debug_eval(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } @@ -1757,16 +1656,12 @@ fn debug_eval( Ok(()) } -fn debug_start( - cx: &mut compositor::Context, - args: &[Cow], - event: PromptEvent, -) -> anyhow::Result<()> { +fn debug_start(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } - let mut args = args.to_owned(); + let mut args: Vec<_> = args.into_iter().collect(); let name = match args.len() { 0 => None, _ => Some(args.remove(0)), @@ -1776,14 +1671,14 @@ fn debug_start( fn debug_remote( cx: &mut compositor::Context, - args: &[Cow], + args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } - let mut args = args.to_owned(); + let mut args: Vec<_> = args.into_iter().collect(); let address = match args.len() { 0 => None, _ => Some(args.remove(0).parse()?), @@ -1795,11 +1690,7 @@ fn debug_remote( dap_start_impl(cx, name.as_deref(), address, Some(args)) } -fn tutor( - cx: &mut compositor::Context, - _args: &[Cow], - event: PromptEvent, -) -> anyhow::Result<()> { +fn tutor(cx: &mut compositor::Context, _args: Args, event: PromptEvent) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } @@ -1821,10 +1712,7 @@ fn abort_goto_line_number_preview(cx: &mut compositor::Context) { } } -fn update_goto_line_number_preview( - cx: &mut compositor::Context, - args: &[Cow], -) -> anyhow::Result<()> { +fn update_goto_line_number_preview(cx: &mut compositor::Context, args: Args) -> anyhow::Result<()> { cx.editor.last_selection.get_or_insert_with(|| { let (view, doc) = current!(cx.editor); doc.selection(view.id).clone() @@ -1842,14 +1730,12 @@ fn update_goto_line_number_preview( pub(super) fn goto_line_number( cx: &mut compositor::Context, - args: &[Cow], + args: Args, event: PromptEvent, ) -> anyhow::Result<()> { match event { PromptEvent::Abort => abort_goto_line_number_preview(cx), PromptEvent::Validate => { - ensure!(!args.is_empty(), "Line number required"); - // If we are invoked directly via a keybinding, Validate is // sent without any prior Update events. Ensure the cursor // is moved to the appropriate location. @@ -1876,19 +1762,11 @@ pub(super) fn goto_line_number( } // Fetch the current value of a config option and output as status. -fn get_option( - cx: &mut compositor::Context, - args: &[Cow], - event: PromptEvent, -) -> anyhow::Result<()> { +fn get_option(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } - if args.len() != 1 { - anyhow::bail!("Bad arguments. Usage: `:get key`"); - } - let key = &args[0].to_lowercase(); let key_error = || anyhow::anyhow!("Unknown key `{}`", key); @@ -1902,19 +1780,12 @@ fn get_option( /// Change config at runtime. Access nested values by dot syntax, for /// example to disable smart case search, use `:set search.smart-case false`. -fn set_option( - cx: &mut compositor::Context, - args: &[Cow], - event: PromptEvent, -) -> anyhow::Result<()> { +fn set_option(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } - if args.len() != 2 { - anyhow::bail!("Bad arguments. Usage: `:set key field`"); - } - let (key, arg) = (&args[0].to_lowercase(), &args[1]); + let (key, arg) = (&args[0].to_lowercase(), args[1].trim()); let key_error = || anyhow::anyhow!("Unknown key `{}`", key); let field_error = |_| anyhow::anyhow!("Could not parse field `{}`", arg); @@ -1943,16 +1814,13 @@ fn set_option( /// case`. fn toggle_option( cx: &mut compositor::Context, - args: &[Cow], + args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } - if args.is_empty() { - anyhow::bail!("Bad arguments. Usage: `:toggle key [values]?`"); - } let key = &args[0].to_lowercase(); let key_error = || anyhow::anyhow!("Unknown key `{}`", key); @@ -1971,42 +1839,57 @@ fn toggle_option( } Value::String(ref value) => { ensure!( - args.len() > 2, + args.len() == 2, "Bad arguments. For string configurations use: `:toggle key val1 val2 ...`", ); + // For string values, parse the input according to normal command line rules. + let values: Vec<_> = command_line::Tokenizer::new(&args[1], true) + .map(|res| res.map(|token| token.content)) + .collect::>() + .map_err(|err| anyhow!("failed to parse values: {err}"))?; Value::String( - args[1..] + values .iter() .skip_while(|e| *e != value) .nth(1) - .unwrap_or_else(|| &args[1]) + .map(AsRef::as_ref) + .unwrap_or_else(|| &values[0]) .to_string(), ) } - Value::Number(ref value) => { + Value::Null => unreachable!("null configuration does not exist"), + Value::Number(_) | Value::Array(_) | Value::Object(_) => { ensure!( - args.len() > 2, + args.len() == 2, "Bad arguments. For number configurations use: `:toggle key val1 val2 ...`", ); + // For numbers, arrays and objects, parse each argument with + // `serde_json::StreamDeserializer`. + let values: Vec = serde_json::Deserializer::from_str(&args[1]) + .into_iter() + .collect::>() + .map_err(|err| anyhow!("failed to parse value: {err}"))?; - Value::Number( - args[1..] - .iter() - .skip_while(|&e| value.to_string() != *e.to_string()) - .nth(1) - .unwrap_or_else(|| &args[1]) - .parse()?, - ) - } - Value::Null | Value::Object(_) | Value::Array(_) => { - anyhow::bail!("Configuration {key} does not support toggle yet") + if let Some(wrongly_typed_value) = values + .iter() + .find(|v| std::mem::discriminant(*v) != std::mem::discriminant(&*value)) + { + bail!("value '{wrongly_typed_value}' has a different type than '{value}'"); + } + + values + .iter() + .skip_while(|e| *e != value) + .nth(1) + .unwrap_or(&values[0]) + .clone() } }; let status = format!("'{key}' is now set to {value}"); let config = serde_json::from_value(config) - .map_err(|err| anyhow::anyhow!("Cannot parse `{:?}`, {}", &args, err))?; + .map_err(|err| anyhow::anyhow!("Failed to parse config: {err}"))?; cx.editor .config_events @@ -2017,11 +1900,7 @@ fn toggle_option( } /// Change the language of the current buffer at runtime. -fn language( - cx: &mut compositor::Context, - args: &[Cow], - event: PromptEvent, -) -> anyhow::Result<()> { +fn language(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } @@ -2033,13 +1912,9 @@ fn language( return Ok(()); } - if args.len() != 1 { - anyhow::bail!("Bad arguments. Usage: `:set-language language`"); - } - let doc = doc_mut!(cx.editor); - if args[0] == DEFAULT_LANGUAGE_NAME { + if &args[0] == DEFAULT_LANGUAGE_NAME { doc.set_language(None, None) } else { doc.set_language_by_language_id(&args[0], cx.editor.syn_loader.clone())?; @@ -2055,31 +1930,15 @@ fn language( Ok(()) } -fn sort(cx: &mut compositor::Context, args: &[Cow], event: PromptEvent) -> anyhow::Result<()> { +fn sort(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } - sort_impl(cx, args, false) + sort_impl(cx, args.has_flag("reverse")) } -fn sort_reverse( - cx: &mut compositor::Context, - args: &[Cow], - event: PromptEvent, -) -> anyhow::Result<()> { - if event != PromptEvent::Validate { - return Ok(()); - } - - sort_impl(cx, args, true) -} - -fn sort_impl( - cx: &mut compositor::Context, - _args: &[Cow], - reverse: bool, -) -> anyhow::Result<()> { +fn sort_impl(cx: &mut compositor::Context, reverse: bool) -> anyhow::Result<()> { let scrolloff = cx.editor.config().scrolloff; let (view, doc) = current!(cx.editor); let text = doc.text().slice(..); @@ -2111,11 +1970,7 @@ fn sort_impl( Ok(()) } -fn reflow( - cx: &mut compositor::Context, - args: &[Cow], - event: PromptEvent, -) -> anyhow::Result<()> { +fn reflow(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } @@ -2154,7 +2009,7 @@ fn reflow( fn tree_sitter_subtree( cx: &mut compositor::Context, - _args: &[Cow], + _args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -2193,7 +2048,7 @@ fn tree_sitter_subtree( fn open_config( cx: &mut compositor::Context, - _args: &[Cow], + _args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -2207,7 +2062,7 @@ fn open_config( fn open_workspace_config( cx: &mut compositor::Context, - _args: &[Cow], + _args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -2219,11 +2074,7 @@ fn open_workspace_config( Ok(()) } -fn open_log( - cx: &mut compositor::Context, - _args: &[Cow], - event: PromptEvent, -) -> anyhow::Result<()> { +fn open_log(cx: &mut compositor::Context, _args: Args, event: PromptEvent) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } @@ -2234,7 +2085,7 @@ fn open_log( fn refresh_config( cx: &mut compositor::Context, - _args: &[Cow], + _args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -2247,47 +2098,41 @@ fn refresh_config( fn append_output( cx: &mut compositor::Context, - args: &[Cow], + args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } - ensure!(!args.is_empty(), "Shell command required"); - shell(cx, &args.join(" "), &ShellBehavior::Append); + shell(cx, &args[0], &ShellBehavior::Append); Ok(()) } fn insert_output( cx: &mut compositor::Context, - args: &[Cow], + args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } - ensure!(!args.is_empty(), "Shell command required"); - shell(cx, &args.join(" "), &ShellBehavior::Insert); + shell(cx, &args[0], &ShellBehavior::Insert); Ok(()) } -fn pipe_to( - cx: &mut compositor::Context, - args: &[Cow], - event: PromptEvent, -) -> anyhow::Result<()> { +fn pipe_to(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> anyhow::Result<()> { pipe_impl(cx, args, event, &ShellBehavior::Ignore) } -fn pipe(cx: &mut compositor::Context, args: &[Cow], event: PromptEvent) -> anyhow::Result<()> { +fn pipe(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> anyhow::Result<()> { pipe_impl(cx, args, event, &ShellBehavior::Replace) } fn pipe_impl( cx: &mut compositor::Context, - args: &[Cow], + args: Args, event: PromptEvent, behavior: &ShellBehavior, ) -> anyhow::Result<()> { @@ -2295,14 +2140,13 @@ fn pipe_impl( return Ok(()); } - ensure!(!args.is_empty(), "Shell command required"); - shell(cx, &args.join(" "), behavior); + shell(cx, &args[0], behavior); Ok(()) } fn run_shell_command( cx: &mut compositor::Context, - args: &[Cow], + args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -2310,7 +2154,7 @@ fn run_shell_command( } let shell = cx.editor.config().shell.clone(); - let args = args.join(" "); + let args = args[0].to_string(); let callback = async move { let output = shell_impl_async(&shell, &args, None).await?; @@ -2338,13 +2182,12 @@ fn run_shell_command( fn reset_diff_change( cx: &mut compositor::Context, - args: &[Cow], + _args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } - ensure!(args.is_empty(), ":reset-diff-change takes no arguments"); let editor = &mut cx.editor; let scrolloff = editor.config().scrolloff; @@ -2391,14 +2234,13 @@ fn reset_diff_change( fn clear_register( cx: &mut compositor::Context, - args: &[Cow], + args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } - ensure!(args.len() <= 1, ":clear-register takes at most 1 argument"); if args.is_empty() { cx.editor.registers.clear(); cx.editor.set_status("All registers cleared"); @@ -2407,7 +2249,7 @@ fn clear_register( ensure!( args[0].chars().count() == 1, - format!("Invalid register {}", args[0]) + format!("Invalid register {}", &args[0]) ); let register = args[0].chars().next().unwrap_or_default(); if cx.editor.registers.remove(register) { @@ -2420,11 +2262,7 @@ fn clear_register( Ok(()) } -fn redraw( - cx: &mut compositor::Context, - _args: &[Cow], - event: PromptEvent, -) -> anyhow::Result<()> { +fn redraw(cx: &mut compositor::Context, _args: Args, event: PromptEvent) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } @@ -2443,16 +2281,11 @@ fn redraw( Ok(()) } -fn move_buffer( - cx: &mut compositor::Context, - args: &[Cow], - event: PromptEvent, -) -> anyhow::Result<()> { +fn move_buffer(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } - ensure!(args.len() == 1, format!(":move takes one argument")); let doc = doc!(cx.editor); let old_path = doc .path() @@ -2467,7 +2300,7 @@ fn move_buffer( fn yank_diagnostic( cx: &mut compositor::Context, - args: &[Cow], + args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -2505,7 +2338,7 @@ fn yank_diagnostic( Ok(()) } -fn read(cx: &mut compositor::Context, args: &[Cow], event: PromptEvent) -> anyhow::Result<()> { +fn read(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } @@ -2513,9 +2346,6 @@ fn read(cx: &mut compositor::Context, args: &[Cow], event: PromptEvent) -> let scrolloff = cx.editor.config().scrolloff; let (view, doc) = current!(cx.editor); - ensure!(!args.is_empty(), "file name is expected"); - ensure!(args.len() == 1, "only the file name is expected"); - let filename = args.first().unwrap(); let path = helix_stdx::path::expand_tilde(PathBuf::from(filename.to_string())); @@ -2539,132 +2369,225 @@ fn read(cx: &mut compositor::Context, args: &[Cow], event: PromptEvent) -> Ok(()) } +fn echo(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> anyhow::Result<()> { + if event != PromptEvent::Validate { + return Ok(()); + } + + let output = args.into_iter().fold(String::new(), |mut acc, arg| { + if !acc.is_empty() { + acc.push(' '); + } + acc.push_str(&arg); + acc + }); + cx.editor.set_status(output); + + Ok(()) +} + +fn noop(_cx: &mut compositor::Context, _args: Args, _event: PromptEvent) -> anyhow::Result<()> { + Ok(()) +} + pub const TYPABLE_COMMAND_LIST: &[TypableCommand] = &[ TypableCommand { name: "quit", aliases: &["q"], doc: "Close the current view.", fun: quit, - signature: CommandSignature::none(), + completer: CommandCompleter::none(), + signature: Signature { + positionals: (0, Some(0)), + ..Signature::default() + }, }, TypableCommand { name: "quit!", aliases: &["q!"], doc: "Force close the current view, ignoring unsaved changes.", fun: force_quit, - signature: CommandSignature::none(), + completer: CommandCompleter::none(), + signature: Signature { + positionals: (0, Some(0)), + ..Signature::default() + }, }, TypableCommand { name: "open", aliases: &["o", "edit", "e"], doc: "Open a file from disk into the current view.", fun: open, - signature: CommandSignature::all(completers::filename), + completer: CommandCompleter::all(completers::filename), + signature: Signature { + positionals: (1, None), + ..Signature::default() + }, }, TypableCommand { name: "buffer-close", aliases: &["bc", "bclose"], doc: "Close the current buffer.", fun: buffer_close, - signature: CommandSignature::all(completers::buffer), + completer: CommandCompleter::all(completers::buffer), + signature: Signature { + positionals: (0, Some(0)), + ..Signature::default() + }, }, TypableCommand { name: "buffer-close!", aliases: &["bc!", "bclose!"], doc: "Close the current buffer forcefully, ignoring unsaved changes.", fun: force_buffer_close, - signature: CommandSignature::all(completers::buffer) + completer: CommandCompleter::all(completers::buffer), + signature: Signature { + positionals: (0, Some(0)), + ..Signature::default() + }, }, TypableCommand { name: "buffer-close-others", aliases: &["bco", "bcloseother"], doc: "Close all buffers but the currently focused one.", fun: buffer_close_others, - signature: CommandSignature::none(), + completer: CommandCompleter::none(), + signature: Signature { + positionals: (0, Some(0)), + ..Signature::default() + }, }, TypableCommand { name: "buffer-close-others!", aliases: &["bco!", "bcloseother!"], doc: "Force close all buffers but the currently focused one.", fun: force_buffer_close_others, - signature: CommandSignature::none(), + completer: CommandCompleter::none(), + signature: Signature { + positionals: (0, Some(0)), + ..Signature::default() + }, }, TypableCommand { name: "buffer-close-all", aliases: &["bca", "bcloseall"], doc: "Close all buffers without quitting.", fun: buffer_close_all, - signature: CommandSignature::none(), + completer: CommandCompleter::none(), + signature: Signature { + positionals: (0, Some(0)), + ..Signature::default() + }, }, TypableCommand { name: "buffer-close-all!", aliases: &["bca!", "bcloseall!"], doc: "Force close all buffers ignoring unsaved changes without quitting.", fun: force_buffer_close_all, - signature: CommandSignature::none(), + completer: CommandCompleter::none(), + signature: Signature { + positionals: (0, Some(0)), + ..Signature::default() + }, }, TypableCommand { name: "buffer-next", aliases: &["bn", "bnext"], doc: "Goto next buffer.", fun: buffer_next, - signature: CommandSignature::none(), + completer: CommandCompleter::none(), + signature: Signature { + positionals: (0, Some(0)), + ..Signature::default() + }, }, TypableCommand { name: "buffer-previous", aliases: &["bp", "bprev"], doc: "Goto previous buffer.", fun: buffer_previous, - signature: CommandSignature::none(), + completer: CommandCompleter::none(), + signature: Signature { + positionals: (0, Some(0)), + ..Signature::default() + }, }, TypableCommand { name: "write", aliases: &["w"], doc: "Write changes to disk. Accepts an optional path (:write some/path.txt)", fun: write, - signature: CommandSignature::positional(&[completers::filename]), + completer: CommandCompleter::positional(&[completers::filename]), + signature: Signature { + positionals: (0, Some(1)), + ..Signature::default() + }, }, TypableCommand { name: "write!", aliases: &["w!"], doc: "Force write changes to disk creating necessary subdirectories. Accepts an optional path (:write! some/path.txt)", fun: force_write, - signature: CommandSignature::positional(&[completers::filename]), + completer: CommandCompleter::positional(&[completers::filename]), + signature: Signature { + positionals: (0, Some(1)), + ..Signature::default() + }, }, TypableCommand { name: "write-buffer-close", aliases: &["wbc"], doc: "Write changes to disk and closes the buffer. Accepts an optional path (:write-buffer-close some/path.txt)", fun: write_buffer_close, - signature: CommandSignature::positional(&[completers::filename]), + completer: CommandCompleter::positional(&[completers::filename]), + signature: Signature { + positionals: (0, Some(1)), + ..Signature::default() + }, }, TypableCommand { name: "write-buffer-close!", aliases: &["wbc!"], doc: "Force write changes to disk creating necessary subdirectories and closes the buffer. Accepts an optional path (:write-buffer-close! some/path.txt)", fun: force_write_buffer_close, - signature: CommandSignature::positional(&[completers::filename]), + completer: CommandCompleter::positional(&[completers::filename]), + signature: Signature { + positionals: (0, Some(1)), + ..Signature::default() + }, }, TypableCommand { name: "new", aliases: &["n"], doc: "Create a new scratch buffer.", fun: new_file, - signature: CommandSignature::none(), + completer: CommandCompleter::none(), + signature: Signature { + positionals: (0, Some(0)), + ..Signature::default() + }, }, TypableCommand { name: "format", aliases: &["fmt"], doc: "Format the file using an external formatter or language server.", fun: format, - signature: CommandSignature::none(), + completer: CommandCompleter::none(), + signature: Signature { + positionals: (0, Some(0)), + ..Signature::default() + }, }, TypableCommand { name: "indent-style", aliases: &[], doc: "Set the indentation style for editing. ('t' for tabs or 1-16 for number of spaces.)", fun: set_indent_style, - signature: CommandSignature::none(), + completer: CommandCompleter::none(), + signature: Signature { + positionals: (0, Some(1)), + ..Signature::default() + }, }, TypableCommand { name: "line-ending", @@ -2674,336 +2597,528 @@ pub const TYPABLE_COMMAND_LIST: &[TypableCommand] = &[ #[cfg(feature = "unicode-lines")] doc: "Set the document's default line ending. Options: crlf, lf, cr, ff, nel.", fun: set_line_ending, - signature: CommandSignature::none(), + completer: CommandCompleter::none(), + signature: Signature { + positionals: (0, Some(1)), + ..Signature::default() + }, }, TypableCommand { name: "earlier", aliases: &["ear"], doc: "Jump back to an earlier point in edit history. Accepts a number of steps or a time span.", fun: earlier, - signature: CommandSignature::none(), + completer: CommandCompleter::none(), + signature: Signature { + positionals: (0, Some(1)), + ..Signature::default() + }, }, TypableCommand { name: "later", aliases: &["lat"], doc: "Jump to a later point in edit history. Accepts a number of steps or a time span.", fun: later, - signature: CommandSignature::none(), + completer: CommandCompleter::none(), + signature: Signature { + positionals: (0, Some(1)), + ..Signature::default() + }, }, TypableCommand { name: "write-quit", aliases: &["wq", "x"], doc: "Write changes to disk and close the current view. Accepts an optional path (:wq some/path.txt)", fun: write_quit, - signature: CommandSignature::positional(&[completers::filename]), + completer: CommandCompleter::positional(&[completers::filename]), + signature: Signature { + positionals: (0, Some(1)), + ..Signature::default() + }, }, TypableCommand { name: "write-quit!", aliases: &["wq!", "x!"], doc: "Write changes to disk and close the current view forcefully. Accepts an optional path (:wq! some/path.txt)", fun: force_write_quit, - signature: CommandSignature::positional(&[completers::filename]), + completer: CommandCompleter::positional(&[completers::filename]), + signature: Signature { + positionals: (0, Some(1)), + ..Signature::default() + }, }, TypableCommand { name: "write-all", aliases: &["wa"], doc: "Write changes from all buffers to disk.", fun: write_all, - signature: CommandSignature::none(), + completer: CommandCompleter::none(), + signature: Signature { + positionals: (0, Some(0)), + ..Signature::default() + }, }, TypableCommand { name: "write-all!", aliases: &["wa!"], doc: "Forcefully write changes from all buffers to disk creating necessary subdirectories.", fun: force_write_all, - signature: CommandSignature::none(), + completer: CommandCompleter::none(), + signature: Signature { + positionals: (0, Some(0)), + ..Signature::default() + }, }, TypableCommand { name: "write-quit-all", aliases: &["wqa", "xa"], doc: "Write changes from all buffers to disk and close all views.", fun: write_all_quit, - signature: CommandSignature::none(), + completer: CommandCompleter::none(), + signature: Signature { + positionals: (0, Some(0)), + ..Signature::default() + }, }, TypableCommand { name: "write-quit-all!", aliases: &["wqa!", "xa!"], doc: "Write changes from all buffers to disk and close all views forcefully (ignoring unsaved changes).", fun: force_write_all_quit, - signature: CommandSignature::none(), + completer: CommandCompleter::none(), + signature: Signature { + positionals: (0, Some(0)), + ..Signature::default() + }, }, TypableCommand { name: "quit-all", aliases: &["qa"], doc: "Close all views.", fun: quit_all, - signature: CommandSignature::none(), + completer: CommandCompleter::none(), + signature: Signature { + positionals: (0, Some(0)), + ..Signature::default() + }, }, TypableCommand { name: "quit-all!", aliases: &["qa!"], doc: "Force close all views ignoring unsaved changes.", fun: force_quit_all, - signature: CommandSignature::none(), + completer: CommandCompleter::none(), + signature: Signature { + positionals: (0, Some(0)), + ..Signature::default() + }, }, TypableCommand { name: "cquit", aliases: &["cq"], doc: "Quit with exit code (default 1). Accepts an optional integer exit code (:cq 2).", fun: cquit, - signature: CommandSignature::none(), + completer: CommandCompleter::none(), + signature: Signature { + positionals: (0, Some(1)), + ..Signature::default() + }, }, TypableCommand { name: "cquit!", aliases: &["cq!"], doc: "Force quit with exit code (default 1) ignoring unsaved changes. Accepts an optional integer exit code (:cq! 2).", fun: force_cquit, - signature: CommandSignature::none(), + completer: CommandCompleter::none(), + signature: Signature { + positionals: (0, Some(1)), + ..Signature::default() + }, }, TypableCommand { name: "theme", aliases: &[], doc: "Change the editor theme (show current theme if no name specified).", fun: theme, - signature: CommandSignature::positional(&[completers::theme]), + completer: CommandCompleter::positional(&[completers::theme]), + signature: Signature { + positionals: (1, Some(1)), + ..Signature::default() + }, }, TypableCommand { name: "yank-join", aliases: &[], doc: "Yank joined selections. A separator can be provided as first argument. Default value is newline.", fun: yank_joined, - signature: CommandSignature::none(), + completer: CommandCompleter::none(), + signature: Signature { + positionals: (0, Some(1)), + ..Signature::default() + }, }, TypableCommand { name: "clipboard-yank", aliases: &[], doc: "Yank main selection into system clipboard.", fun: yank_main_selection_to_clipboard, - signature: CommandSignature::none(), + completer: CommandCompleter::none(), + signature: Signature { + positionals: (0, Some(0)), + ..Signature::default() + }, }, TypableCommand { name: "clipboard-yank-join", aliases: &[], doc: "Yank joined selections into system clipboard. A separator can be provided as first argument. Default value is newline.", // FIXME: current UI can't display long doc. fun: yank_joined_to_clipboard, - signature: CommandSignature::none(), + completer: CommandCompleter::none(), + signature: Signature { + positionals: (0, Some(1)), + ..Signature::default() + }, }, TypableCommand { name: "primary-clipboard-yank", aliases: &[], doc: "Yank main selection into system primary clipboard.", fun: yank_main_selection_to_primary_clipboard, - signature: CommandSignature::none(), + completer: CommandCompleter::none(), + signature: Signature { + positionals: (0, Some(0)), + ..Signature::default() + }, }, TypableCommand { name: "primary-clipboard-yank-join", aliases: &[], doc: "Yank joined selections into system primary clipboard. A separator can be provided as first argument. Default value is newline.", // FIXME: current UI can't display long doc. fun: yank_joined_to_primary_clipboard, - signature: CommandSignature::none(), + completer: CommandCompleter::none(), + signature: Signature { + positionals: (0, Some(1)), + ..Signature::default() + }, }, TypableCommand { name: "clipboard-paste-after", aliases: &[], doc: "Paste system clipboard after selections.", fun: paste_clipboard_after, - signature: CommandSignature::none(), + completer: CommandCompleter::none(), + signature: Signature { + positionals: (0, Some(0)), + ..Signature::default() + }, }, TypableCommand { name: "clipboard-paste-before", aliases: &[], doc: "Paste system clipboard before selections.", fun: paste_clipboard_before, - signature: CommandSignature::none(), + completer: CommandCompleter::none(), + signature: Signature { + positionals: (0, Some(0)), + ..Signature::default() + }, }, TypableCommand { name: "clipboard-paste-replace", aliases: &[], doc: "Replace selections with content of system clipboard.", fun: replace_selections_with_clipboard, - signature: CommandSignature::none(), + completer: CommandCompleter::none(), + signature: Signature { + positionals: (0, Some(0)), + ..Signature::default() + }, }, TypableCommand { name: "primary-clipboard-paste-after", aliases: &[], doc: "Paste primary clipboard after selections.", fun: paste_primary_clipboard_after, - signature: CommandSignature::none(), + completer: CommandCompleter::none(), + signature: Signature { + positionals: (0, Some(0)), + ..Signature::default() + }, }, TypableCommand { name: "primary-clipboard-paste-before", aliases: &[], doc: "Paste primary clipboard before selections.", fun: paste_primary_clipboard_before, - signature: CommandSignature::none(), + completer: CommandCompleter::none(), + signature: Signature { + positionals: (0, Some(0)), + ..Signature::default() + }, }, TypableCommand { name: "primary-clipboard-paste-replace", aliases: &[], doc: "Replace selections with content of system primary clipboard.", fun: replace_selections_with_primary_clipboard, - signature: CommandSignature::none(), + completer: CommandCompleter::none(), + signature: Signature { + positionals: (0, Some(0)), + ..Signature::default() + }, }, TypableCommand { name: "show-clipboard-provider", aliases: &[], doc: "Show clipboard provider name in status bar.", fun: show_clipboard_provider, - signature: CommandSignature::none(), + completer: CommandCompleter::none(), + signature: Signature { + positionals: (0, Some(0)), + ..Signature::default() + }, }, TypableCommand { name: "change-current-directory", aliases: &["cd"], doc: "Change the current working directory.", fun: change_current_directory, - signature: CommandSignature::positional(&[completers::directory]), + completer: CommandCompleter::positional(&[completers::directory]), + signature: Signature { + positionals: (1, Some(1)), + ..Signature::default() + }, }, TypableCommand { name: "show-directory", aliases: &["pwd"], doc: "Show the current working directory.", fun: show_current_directory, - signature: CommandSignature::none(), + completer: CommandCompleter::none(), + signature: Signature { + positionals: (0, Some(0)), + ..Signature::default() + }, }, TypableCommand { name: "encoding", aliases: &[], doc: "Set encoding. Based on `https://encoding.spec.whatwg.org`.", fun: set_encoding, - signature: CommandSignature::none(), + completer: CommandCompleter::none(), + signature: Signature { + positionals: (0, Some(1)), + ..Signature::default() + }, }, TypableCommand { name: "character-info", aliases: &["char"], doc: "Get info about the character under the primary cursor.", fun: get_character_info, - signature: CommandSignature::none(), + completer: CommandCompleter::none(), + signature: Signature { + positionals: (0, Some(0)), + ..Signature::default() + }, }, TypableCommand { name: "reload", aliases: &["rl"], doc: "Discard changes and reload from the source file.", fun: reload, - signature: CommandSignature::none(), + completer: CommandCompleter::none(), + signature: Signature { + positionals: (0, Some(0)), + ..Signature::default() + }, }, TypableCommand { name: "reload-all", aliases: &["rla"], doc: "Discard changes and reload all documents from the source files.", fun: reload_all, - signature: CommandSignature::none(), + completer: CommandCompleter::none(), + signature: Signature { + positionals: (0, Some(0)), + ..Signature::default() + }, }, TypableCommand { name: "update", aliases: &["u"], doc: "Write changes only if the file has been modified.", fun: update, - signature: CommandSignature::none(), + completer: CommandCompleter::none(), + signature: Signature { + positionals: (0, Some(0)), + ..Signature::default() + }, }, TypableCommand { name: "lsp-workspace-command", aliases: &[], doc: "Open workspace command picker", fun: lsp_workspace_command, - signature: CommandSignature::positional(&[completers::lsp_workspace_command]), + completer: CommandCompleter::positional(&[completers::lsp_workspace_command]), + signature: Signature { + positionals: (0, Some(1)), + ..Signature::default() + }, }, TypableCommand { name: "lsp-restart", aliases: &[], doc: "Restarts the language servers used by the current doc", fun: lsp_restart, - signature: CommandSignature::none(), + completer: CommandCompleter::none(), + signature: Signature { + positionals: (0, Some(0)), + ..Signature::default() + }, }, TypableCommand { name: "lsp-stop", aliases: &[], doc: "Stops the language servers that are used by the current doc", fun: lsp_stop, - signature: CommandSignature::none(), + completer: CommandCompleter::none(), + signature: Signature { + positionals: (0, Some(0)), + ..Signature::default() + }, }, TypableCommand { name: "tree-sitter-scopes", aliases: &[], doc: "Display tree sitter scopes, primarily for theming and development.", fun: tree_sitter_scopes, - signature: CommandSignature::none(), + completer: CommandCompleter::none(), + signature: Signature { + positionals: (0, Some(0)), + ..Signature::default() + }, }, TypableCommand { name: "tree-sitter-highlight-name", aliases: &[], doc: "Display name of tree-sitter highlight scope under the cursor.", fun: tree_sitter_highlight_name, - signature: CommandSignature::none(), + completer: CommandCompleter::none(), + signature: Signature { + positionals: (0, Some(0)), + ..Signature::default() + }, }, TypableCommand { name: "debug-start", aliases: &["dbg"], doc: "Start a debug session from a given template with given parameters.", fun: debug_start, - signature: CommandSignature::none(), + completer: CommandCompleter::none(), + signature: Signature { + positionals: (0, None), + ..Signature::default() + }, }, TypableCommand { name: "debug-remote", aliases: &["dbg-tcp"], doc: "Connect to a debug adapter by TCP address and start a debugging session from a given template with given parameters.", fun: debug_remote, - signature: CommandSignature::none(), + completer: CommandCompleter::none(), + signature: Signature { + positionals: (0, None), + ..Signature::default() + }, }, TypableCommand { name: "debug-eval", aliases: &[], doc: "Evaluate expression in current debug context.", fun: debug_eval, - signature: CommandSignature::none(), + completer: CommandCompleter::none(), + signature: Signature { + positionals: (1, Some(1)), + ..Signature::default() + }, }, TypableCommand { name: "vsplit", aliases: &["vs"], doc: "Open the file in a vertical split.", fun: vsplit, - signature: CommandSignature::all(completers::filename) + completer: CommandCompleter::all(completers::filename), + signature: Signature { + positionals: (0, None), + ..Signature::default() + }, }, TypableCommand { name: "vsplit-new", aliases: &["vnew"], doc: "Open a scratch buffer in a vertical split.", fun: vsplit_new, - signature: CommandSignature::none(), + completer: CommandCompleter::none(), + signature: Signature { + positionals: (0, Some(0)), + ..Signature::default() + }, }, TypableCommand { name: "hsplit", aliases: &["hs", "sp"], doc: "Open the file in a horizontal split.", fun: hsplit, - signature: CommandSignature::all(completers::filename) + completer: CommandCompleter::all(completers::filename), + signature: Signature { + positionals: (0, None), + ..Signature::default() + }, }, TypableCommand { name: "hsplit-new", aliases: &["hnew"], doc: "Open a scratch buffer in a horizontal split.", fun: hsplit_new, - signature: CommandSignature::none(), + completer: CommandCompleter::none(), + signature: Signature { + positionals: (0, Some(0)), + ..Signature::default() + }, }, TypableCommand { name: "tutor", aliases: &[], doc: "Open the tutorial.", fun: tutor, - signature: CommandSignature::none(), + completer: CommandCompleter::none(), + signature: Signature { + positionals: (0, Some(0)), + ..Signature::default() + }, }, TypableCommand { name: "goto", aliases: &["g"], doc: "Goto line number.", fun: goto_line_number, - signature: CommandSignature::none(), + completer: CommandCompleter::none(), + signature: Signature { + positionals: (1, Some(1)), + ..Signature::default() + }, }, TypableCommand { name: "set-language", aliases: &["lang"], doc: "Set the language of current buffer (show current language if no value specified).", fun: language, - signature: CommandSignature::positional(&[completers::language]), + completer: CommandCompleter::positional(&[completers::language]), + signature: Signature { + positionals: (0, Some(1)), + ..Signature::default() + }, }, TypableCommand { name: "set-option", @@ -3011,154 +3126,248 @@ pub const TYPABLE_COMMAND_LIST: &[TypableCommand] = &[ doc: "Set a config option at runtime.\nFor example to disable smart case search, use `:set search.smart-case false`.", fun: set_option, // TODO: Add support for completion of the options value(s), when appropriate. - signature: CommandSignature::positional(&[completers::setting]), + completer: CommandCompleter::positional(&[completers::setting]), + signature: Signature { + positionals: (2, Some(2)), + raw_after: Some(1), + ..Signature::default() + }, }, TypableCommand { name: "toggle-option", aliases: &["toggle"], - doc: "Toggle a boolean config option at runtime.\nFor example to toggle smart case search, use `:toggle search.smart-case`.", + doc: "Toggle a config option at runtime.\nFor example to toggle smart case search, use `:toggle search.smart-case`.", fun: toggle_option, - signature: CommandSignature::positional(&[completers::setting]), + completer: CommandCompleter::positional(&[completers::setting]), + signature: Signature { + positionals: (1, None), + raw_after: Some(1), + ..Signature::default() + }, }, TypableCommand { name: "get-option", aliases: &["get"], doc: "Get the current value of a config option.", fun: get_option, - signature: CommandSignature::positional(&[completers::setting]), + completer: CommandCompleter::positional(&[completers::setting]), + signature: Signature { + positionals: (1, Some(1)), + ..Signature::default() + }, }, TypableCommand { name: "sort", aliases: &[], doc: "Sort ranges in selection.", fun: sort, - signature: CommandSignature::none(), - }, - TypableCommand { - name: "rsort", - aliases: &[], - doc: "Sort ranges in selection in reverse order.", - fun: sort_reverse, - signature: CommandSignature::none(), + completer: CommandCompleter::none(), + signature: Signature { + positionals: (0, Some(0)), + flags: &[ + Flag { + name: "reverse", + alias: Some('r'), + doc: "sort ranges in reverse order", + ..Flag::default() + }, + ], + ..Signature::default() + }, }, TypableCommand { name: "reflow", aliases: &[], doc: "Hard-wrap the current selection of lines to a given width.", fun: reflow, - signature: CommandSignature::none(), + completer: CommandCompleter::none(), + signature: Signature { + positionals: (0, Some(0)), + ..Signature::default() + }, }, TypableCommand { name: "tree-sitter-subtree", aliases: &["ts-subtree"], doc: "Display the smallest tree-sitter subtree that spans the primary selection, primarily for debugging queries.", fun: tree_sitter_subtree, - signature: CommandSignature::none(), + completer: CommandCompleter::none(), + signature: Signature { + positionals: (0, Some(0)), + ..Signature::default() + }, }, TypableCommand { name: "config-reload", aliases: &[], doc: "Refresh user config.", fun: refresh_config, - signature: CommandSignature::none(), + completer: CommandCompleter::none(), + signature: Signature { + positionals: (0, Some(0)), + ..Signature::default() + }, }, TypableCommand { name: "config-open", aliases: &[], doc: "Open the user config.toml file.", fun: open_config, - signature: CommandSignature::none(), + completer: CommandCompleter::none(), + signature: Signature { + positionals: (0, Some(0)), + ..Signature::default() + }, }, TypableCommand { name: "config-open-workspace", aliases: &[], doc: "Open the workspace config.toml file.", fun: open_workspace_config, - signature: CommandSignature::none(), + completer: CommandCompleter::none(), + signature: Signature { + positionals: (0, Some(0)), + ..Signature::default() + }, }, TypableCommand { name: "log-open", aliases: &[], doc: "Open the helix log file.", fun: open_log, - signature: CommandSignature::none(), + completer: CommandCompleter::none(), + signature: Signature { + positionals: (0, Some(0)), + ..Signature::default() + }, }, TypableCommand { name: "insert-output", aliases: &[], doc: "Run shell command, inserting output before each selection.", fun: insert_output, - signature: CommandSignature::none(), + completer: CommandCompleter::none(), + signature: Signature::raw(), }, TypableCommand { name: "append-output", aliases: &[], doc: "Run shell command, appending output after each selection.", fun: append_output, - signature: CommandSignature::none(), + completer: CommandCompleter::none(), + signature: Signature::raw(), }, TypableCommand { name: "pipe", aliases: &[], doc: "Pipe each selection to the shell command.", fun: pipe, - signature: CommandSignature::none(), + completer: CommandCompleter::none(), + signature: Signature::raw(), }, TypableCommand { name: "pipe-to", aliases: &[], doc: "Pipe each selection to the shell command, ignoring output.", fun: pipe_to, - signature: CommandSignature::none(), + completer: CommandCompleter::none(), + signature: Signature::raw(), }, TypableCommand { name: "run-shell-command", aliases: &["sh"], doc: "Run a shell command", fun: run_shell_command, - signature: CommandSignature::all(completers::filename) + completer: CommandCompleter::all(completers::filename), + signature: Signature::raw(), }, TypableCommand { name: "reset-diff-change", aliases: &["diffget", "diffg"], doc: "Reset the diff change at the cursor position.", fun: reset_diff_change, - signature: CommandSignature::none(), + completer: CommandCompleter::none(), + signature: Signature { + positionals: (0, Some(0)), + ..Signature::default() + }, }, TypableCommand { name: "clear-register", aliases: &[], doc: "Clear given register. If no argument is provided, clear all registers.", fun: clear_register, - signature: CommandSignature::all(completers::register), + completer: CommandCompleter::all(completers::register), + signature: Signature { + positionals: (0, Some(1)), + ..Signature::default() + }, }, TypableCommand { name: "redraw", aliases: &[], doc: "Clear and re-render the whole UI", fun: redraw, - signature: CommandSignature::none(), + completer: CommandCompleter::none(), + signature: Signature { + positionals: (0, Some(0)), + ..Signature::default() + }, }, TypableCommand { name: "move", aliases: &["mv"], doc: "Move the current buffer and its corresponding file to a different path", fun: move_buffer, - signature: CommandSignature::positional(&[completers::filename]), + completer: CommandCompleter::positional(&[completers::filename]), + signature: Signature { + positionals: (1, Some(1)), + ..Signature::default() + }, }, TypableCommand { name: "yank-diagnostic", aliases: &[], doc: "Yank diagnostic(s) under primary cursor to register, or clipboard by default", fun: yank_diagnostic, - signature: CommandSignature::all(completers::register), + completer: CommandCompleter::all(completers::register), + signature: Signature { + positionals: (0, Some(1)), + ..Signature::default() + }, }, TypableCommand { name: "read", aliases: &["r"], doc: "Load a file into buffer", fun: read, - signature: CommandSignature::positional(&[completers::filename]), + completer: CommandCompleter::positional(&[completers::filename]), + signature: Signature { + positionals: (1, Some(1)), + ..Signature::default() + }, + }, + TypableCommand { + name: "echo", + aliases: &[], + doc: "Prints the given arguments to the statusline.", + fun: echo, + completer: CommandCompleter::none(), + signature: Signature { + positionals: (1, None), + ..Signature::default() + }, + }, + TypableCommand { + name: "noop", + aliases: &[], + doc: "Does nothing.", + fun: noop, + completer: CommandCompleter::none(), + signature: Signature { + positionals: (0, None), + ..Signature::default() + }, }, ]; @@ -3173,125 +3382,370 @@ pub static TYPABLE_COMMAND_MAP: Lazy anyhow::Result<()> { + let (command, rest, _) = command_line::split(input); + if command.is_empty() { + return Ok(()); + } + + // If command is numeric, interpret as line number and go there. + if command.parse::().is_ok() && rest.trim().is_empty() { + let cmd = TYPABLE_COMMAND_MAP.get("goto").unwrap(); + return execute_command(cx, cmd, command, event); + } + + match typed::TYPABLE_COMMAND_MAP.get(command) { + Some(cmd) => execute_command(cx, cmd, rest, event), + None if event == PromptEvent::Validate => Err(anyhow!("no such command: '{command}'")), + None => Ok(()), + } +} + +pub(super) fn execute_command( + cx: &mut compositor::Context, + cmd: &TypableCommand, + args: &str, + event: PromptEvent, +) -> anyhow::Result<()> { + let args = if event == PromptEvent::Validate { + Args::parse(args, cmd.signature, true, |token| { + expansion::expand(cx.editor, token).map_err(|err| err.into()) + }) + .map_err(|err| anyhow!("'{}': {err}", cmd.name))? + } else { + Args::parse(args, cmd.signature, false, |token| Ok(token.content)) + .expect("arg parsing cannot fail when validation is turned off") + }; + + (cmd.fun)(cx, args, event).map_err(|err| anyhow!("'{}': {err}", cmd.name)) +} + #[allow(clippy::unnecessary_unwrap)] pub(super) fn command_mode(cx: &mut Context) { let mut prompt = Prompt::new( ":".into(), Some(':'), - |editor: &Editor, input: &str| { - let shellwords = Shellwords::from(input); - let words = shellwords.words(); - - if words.is_empty() || (words.len() == 1 && !shellwords.ends_with_whitespace()) { - fuzzy_match( - input, - TYPABLE_COMMAND_LIST.iter().map(|command| command.name), - false, - ) - .into_iter() - .map(|(name, _)| (0.., name.into())) - .collect() + complete_command_line, + move |cx: &mut compositor::Context, input: &str, event: PromptEvent| { + if let Err(err) = execute_command_line(cx, input, event) { + cx.editor.set_error(err.to_string()); + } + }, + ); + prompt.doc_fn = Box::new(command_line_doc); + + // Calculate initial completion + prompt.recalculate_completion(cx.editor); + cx.push_layer(Box::new(prompt)); +} + +fn command_line_doc(input: &str) -> Option> { + let (command, _, _) = command_line::split(input); + let command = TYPABLE_COMMAND_MAP.get(command)?; + + if command.aliases.is_empty() && command.signature.flags.is_empty() { + return Some(Cow::Borrowed(command.doc)); + } + + let mut doc = command.doc.to_string(); + + if !command.aliases.is_empty() { + write!(doc, "\nAliases: {}", command.aliases.join(", ")).unwrap(); + } + + if !command.signature.flags.is_empty() { + const ARG_PLACEHOLDER: &str = " "; + + fn flag_len(flag: &Flag) -> usize { + let name_len = flag.name.len(); + let alias_len = if let Some(alias) = flag.alias { + "/-".len() + alias.len_utf8() } else { - // Otherwise, use the command's completer and the last shellword - // as completion input. - let (word, word_len) = if words.len() == 1 || shellwords.ends_with_whitespace() { - (&Cow::Borrowed(""), 0) - } else { - (words.last().unwrap(), words.last().unwrap().len()) - }; + 0 + }; + let arg_len = if flag.completions.is_some() { + ARG_PLACEHOLDER.len() + } else { + 0 + }; + name_len + alias_len + arg_len + } - let argument_number = argument_number_of(&shellwords); + doc.push_str("\nFlags:"); + + let max_flag_len = command.signature.flags.iter().map(flag_len).max().unwrap(); + + for flag in command.signature.flags { + let mut buf = [0u8; 4]; + let this_flag_len = flag_len(flag); + write!( + doc, + "\n --{flag_text}{spacer:spacing$} {doc}", + doc = flag.doc, + // `fmt::Arguments` does not respect width controls so we must place the spacers + // explicitly: + spacer = "", + spacing = max_flag_len - this_flag_len, + flag_text = format_args!( + "{}{}{}{}", + flag.name, + // Ideally this would be written as a `format_args!` too but the borrow + // checker is not yet smart enough. + if flag.alias.is_some() { "/-" } else { "" }, + if let Some(alias) = flag.alias { + alias.encode_utf8(&mut buf) + } else { + "" + }, + if flag.completions.is_some() { + ARG_PLACEHOLDER + } else { + "" + } + ), + ) + .unwrap(); + } + } - if let Some(completer) = TYPABLE_COMMAND_MAP - .get(&words[0] as &str) - .map(|tc| tc.completer_for_argument_number(argument_number)) - { - completer(editor, word) + Some(Cow::Owned(doc)) +} + +fn complete_command_line(editor: &Editor, input: &str) -> Vec { + let (command, rest, complete_command) = command_line::split(input); + + if complete_command { + fuzzy_match( + input, + TYPABLE_COMMAND_LIST.iter().map(|command| command.name), + false, + ) + .into_iter() + .map(|(name, _)| (0.., name.into())) + .collect() + } else { + TYPABLE_COMMAND_MAP + .get(command) + .map_or_else(Vec::new, |cmd| { + let args_offset = command.len() + 1; + complete_command_args(editor, cmd, rest, args_offset) + }) + } +} + +fn complete_command_args( + editor: &Editor, + command: &TypableCommand, + input: &str, + offset: usize, +) -> Vec { + use command_line::{CompletionState, ExpansionKind, Token, TokenKind, Tokenizer}; + + // TODO: completion should depend on the location of the cursor instead of the end of the + // string. This refactor is left for the future but the below completion code should respect + // the cursor position if it becomes a parameter. + let cursor = input.len(); + let prefix = &input[..cursor]; + let mut tokenizer = Tokenizer::new(prefix, false); + let mut args = Args::new(command.signature, false); + let mut final_token = None; + let mut is_last_token = true; + + while let Some(token) = args + .read_token(&mut tokenizer) + .expect("arg parsing cannot fail when validation is turned off") + { + final_token = Some(token.clone()); + args.push(token.content) + .expect("arg parsing cannot fail when validation is turned off"); + if tokenizer.pos() >= cursor { + is_last_token = false; + } + } + + // Use a fake final token when the input is not terminated with a token. This simulates an + // empty argument, causing completion on an empty value whenever you type space/tab. For + // example if you say `":open README.md "` (with that trailing space) you should see the + // files in the current dir - completing `""` rather than completions for `"README.md"` or + // `"README.md "`. + let token = if is_last_token { + let token = Token::empty_at(prefix.len()); + args.push(token.content.clone()).unwrap(); + token + } else { + final_token.unwrap() + }; + + // Don't complete on closed tokens, for example after writing a closing double quote. + if token.is_terminated { + return Vec::new(); + } + + match token.kind { + TokenKind::Unquoted | TokenKind::Quoted(_) => { + match args.completion_state() { + CompletionState::Positional => { + // If the completion state is positional there must be at least one positional + // in `args`. + let n = args + .len() + .checked_sub(1) + .expect("completion state to be positional"); + let completer = command.completer_for_argument_number(n); + + completer(editor, &token.content) .into_iter() - .map(|(range, mut file)| { - file.content = shellwords::escape(file.content); - - // offset ranges to input - let offset = input.len() - word_len; - let range = (range.start + offset)..; - (range, file) - }) + .map(|(range, span)| quote_completion(&token, range, span, offset)) .collect() - } else { - Vec::new() } + CompletionState::Flag(_) => fuzzy_match( + token.content.trim_start_matches('-'), + command.signature.flags.iter().map(|flag| flag.name), + false, + ) + .into_iter() + .map(|(name, _)| ((offset + token.content_start).., format!("--{name}").into())) + .collect(), + CompletionState::FlagArgument(flag) => fuzzy_match( + &token.content, + flag.completions + .expect("flags in FlagArgument always have completions"), + false, + ) + .into_iter() + .map(|(value, _)| ((offset + token.content_start).., (*value).into())) + .collect(), } - }, // completion - move |cx: &mut compositor::Context, input: &str, event: PromptEvent| { - let parts = input.split_whitespace().collect::>(); - if parts.is_empty() { - return; - } + } + TokenKind::Expand | TokenKind::Expansion(ExpansionKind::Shell) => { + complete_expand(token.content.as_ref(), offset + token.content_start) + } + TokenKind::Expansion(ExpansionKind::Variable) => { + complete_variable_expansion(&token.content, offset + token.content_start) + } + TokenKind::Expansion(ExpansionKind::Unicode) => Vec::new(), + TokenKind::ExpansionKind => { + complete_expansion_kind(&token.content, offset + token.content_start) + } + } +} - // If command is numeric, interpret as line number and go there. - if parts.len() == 1 && parts[0].parse::().ok().is_some() { - if let Err(e) = typed::goto_line_number(cx, &[Cow::from(parts[0])], event) { - cx.editor.set_error(format!("{}", e)); - } - return; - } +/// Replace the content and optionally update the range of a positional's completion to account +/// for quoting. +/// +/// This is used to handle completions of file or directory names for example. When completing a +/// file with a space, tab or percent character in the name, the space should be escaped by +/// quoting the entire token. If the token being completed is already quoted, any quotes within +/// the completion text should be escaped by doubling them. +fn quote_completion<'a>( + token: &command_line::Token, + range: ops::RangeFrom, + mut span: Span<'a>, + offset: usize, +) -> (ops::RangeFrom, Span<'a>) { + use command_line::TokenKind; + + fn replace<'a>(text: Cow<'a, str>, from: char, to: &str) -> Cow<'a, str> { + if text.contains(from) { + Cow::Owned(text.replace(from, to)) + } else { + text + } + } - // Handle typable commands - if let Some(cmd) = typed::TYPABLE_COMMAND_MAP.get(parts[0]) { - let shellwords = Shellwords::from(input); - let args = shellwords.words(); + match token.kind { + TokenKind::Unquoted if span.content.contains([' ', '\t', '%']) => { + span.content = Cow::Owned(format!( + "'{}{}'", + // Escape any inner single quotes by doubling them. + replace(token.content.as_ref().into(), '\'', "''"), + replace(span.content, '\'', "''") + )); + ((offset + token.content_start).., span) + } + TokenKind::Quoted(quote) if span.content.contains(quote.char()) => { + span.content = replace(span.content, quote.char(), quote.escape()); + ((range.start + offset + token.content_start).., span) + } + _ => ((range.start + offset + token.content_start).., span), + } +} - if let Err(e) = (cmd.fun)(cx, &args[1..], event) { - cx.editor.set_error(format!("{}", e)); +fn complete_expand(content: &str, offset: usize) -> Vec { + use command_line::{ExpansionKind, TokenKind, Tokenizer}; + + let mut start = 0; + + while let Some(idx) = content[start..].find('%') { + let idx = start + idx; + if content.as_bytes().get(idx + '%'.len_utf8()).copied() == Some(b'%') { + // Two percents together are skipped. + start = idx + ('%'.len_utf8() * 2); + } else { + let mut tokenizer = Tokenizer::new(&content[idx..], false); + let token = tokenizer + .parse_percent_token() + .map(|token| token.expect("arg parser cannot fail when validation is disabled")); + start = idx + tokenizer.pos(); + + // Like closing quote characters in `complete_command_args` above, don't provide + // completions if the token is already terminated. This also skips expansions + // which have already been fully written, for example + // `"%{cursor_line}:%{cursor_col` should complete `cursor_column` instead of + // `cursor_line`. + let Some(token) = token.filter(|t| !t.is_terminated) else { + continue; + }; + + let local_offset = offset + idx + token.content_start; + match token.kind { + TokenKind::Expansion(ExpansionKind::Variable) => { + return complete_variable_expansion(&token.content, local_offset) } - } else if event == PromptEvent::Validate { - cx.editor - .set_error(format!("no such command: '{}'", parts[0])); - } - }, - ); - prompt.doc_fn = Box::new(|input: &str| { - let part = input.split(' ').next().unwrap_or_default(); - - if let Some(typed::TypableCommand { doc, aliases, .. }) = - typed::TYPABLE_COMMAND_MAP.get(part) - { - if aliases.is_empty() { - return Some((*doc).into()); + TokenKind::Expansion(ExpansionKind::Shell) => { + return complete_expand(&token.content, local_offset) + } + TokenKind::ExpansionKind => { + return complete_expansion_kind(&token.content, local_offset) + } + _ => continue, } - return Some(format!("{}\nAliases: {}", doc, aliases.join(", ")).into()); } + } - None - }); - - // Calculate initial completion - prompt.recalculate_completion(cx.editor); - cx.push_layer(Box::new(prompt)); + Vec::new() } -fn argument_number_of(shellwords: &Shellwords) -> usize { - if shellwords.ends_with_whitespace() { - shellwords.words().len().saturating_sub(1) - } else { - shellwords.words().len().saturating_sub(2) - } +fn complete_variable_expansion(content: &str, offset: usize) -> Vec { + use expansion::Variable; + + fuzzy_match( + content, + Variable::VARIANTS.iter().map(Variable::as_str), + false, + ) + .into_iter() + .map(|(name, _)| (offset.., (*name).into())) + .collect() } -#[test] -fn test_argument_number_of() { - let cases = vec![ - ("set-option", 0), - ("set-option ", 0), - ("set-option a", 0), - ("set-option asdf", 0), - ("set-option asdf ", 1), - ("set-option asdf xyz", 1), - ("set-option asdf xyz abc", 2), - ("set-option asdf xyz abc ", 3), - ]; +fn complete_expansion_kind(content: &str, offset: usize) -> Vec { + use command_line::ExpansionKind; - for case in cases { - assert_eq!(case.1, argument_number_of(&Shellwords::from(case.0))); - } + fuzzy_match( + content, + // Skip `ExpansionKind::Variable` since its kind string is empty. + ExpansionKind::VARIANTS + .iter() + .skip(1) + .map(ExpansionKind::as_str), + false, + ) + .into_iter() + .map(|(name, _)| (offset.., (*name).into())) + .collect() } diff --git a/helix-term/src/keymap.rs b/helix-term/src/keymap.rs index 020ecaf40f0ff..214bbb57473db 100644 --- a/helix-term/src/keymap.rs +++ b/helix-term/src/keymap.rs @@ -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(), }, }) diff --git a/helix-term/tests/integration.rs b/helix-term/tests/integration.rs index 35214bcb8011d..5e418cebdabda 100644 --- a/helix-term/tests/integration.rs +++ b/helix-term/tests/integration.rs @@ -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; } diff --git a/helix-term/tests/test/command_line.rs b/helix-term/tests/test/command_line.rs new file mode 100644 index 0000000000000..d9a0166992a86 --- /dev/null +++ b/helix-term/tests/test/command_line.rs @@ -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:theme d"), + 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}")), + 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(()) +} diff --git a/helix-term/tests/test/prompt.rs b/helix-term/tests/test/prompt.rs deleted file mode 100644 index 4f3bf76329dce..0000000000000 --- a/helix-term/tests/test/prompt.rs +++ /dev/null @@ -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:theme d"), - Some(&|app| { - assert!(!app.editor.is_err()); - }), - false, - ) - .await?; - - Ok(()) -} diff --git a/helix-view/src/expansion.rs b/helix-view/src/expansion.rs new file mode 100644 index 0000000000000..875cc62df3a9d --- /dev/null +++ b/helix-view/src/expansion.rs @@ -0,0 +1,223 @@ +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/commands.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 { + 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> { + // 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) => { + use std::process::{Command, Stdio}; + + // TODO: there is no protection here against a shell command taking a long time. + // Ideally you should be able to hit `` in command mode and then be able to + // cancel the invocation (for example with ``) if it takes longer than you'd + // like. + + // Recursively expand the expansion's content before executing the shell command. + let content = expand_inner(editor, token.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()); + + let output = match process.spawn() { + Ok(process) => process.wait_with_output()?, + Err(err) => { + bail!("Failed to start shell: {err}"); + } + }; + + let text = if !output.status.success() { + if output.stderr.is_empty() { + match output.status.code() { + Some(exit_code) => bail!("Shell command failed with status {exit_code}"), + None => bail!("Shell command failed"), + } + } + String::from_utf8_lossy(&output.stderr) + } else if !output.stderr.is_empty() { + // stderr is prioritized over stdout + let stderr = String::from_utf8_lossy(&output.stderr); + log::debug!("Command printed to stderr: {stderr}"); + stderr + } else { + String::from_utf8_lossy(&output.stdout) + }; + + // Trim exactly one trailing newline if it exists. + let mut text = text.into_owned(); + if text.ends_with('\n') { + text.pop(); + } + + Ok(Cow::Owned(text)) + } + // Note: see the docs for this variant. + TokenKind::ExpansionKind => unreachable!( + "expansion name tokens cannot be emitted when command line validation is enabled" + ), + } +} + +/// Expand a token contents recursively. +fn expand_inner<'a>(editor: &Editor, content: Cow<'a, str>) -> Result> { + 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> { + 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())), + } +} diff --git a/helix-view/src/lib.rs b/helix-view/src/lib.rs index d54b49ef54009..e30a233816da3 100644 --- a/helix-view/src/lib.rs +++ b/helix-view/src/lib.rs @@ -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;