Repository: helix-editor/helix (language: rust)

Issue:
Support lsp_workspace_commands with arguments
Description: https://github.com/helix-editor/helix/discussions/9210
Honor whitespace escape sequences in `yank-join`
Say you have the following file:
![image](https://github.com/helix-editor/helix/assets/12220125/81387a4d-6092-4ffc-b865-72b784cb85d8)

And you select just the initial `asdf` bits:
![image](https://github.com/helix-editor/helix/assets/12220125/adf3b0c4-8e23-43c1-92eb-f41e057c0e58)


Then, you enter `:yank-join ,\n`. I think the expected behavior would be this:
![image](https://github.com/helix-editor/helix/assets/12220125/d86a8d56-a85b-4605-a221-a2accbdc05fa)

But instead, you get this:
![image](https://github.com/helix-editor/helix/assets/12220125/d29d0893-a7b4-4566-bba7-b0513eb19b06)


This isn't a very big deal, as it's pretty easy to just go ahead with the default newline separator and add things as you wish, but it could be pretty convenient to just do it all at once.

I dug into the code a bit, and I think that the `ShellWords::From<&str>` implementation is to blame here. I suspect there's quite good reason for it, but I'm wondering if there might be a reasonable workaround.

I would be happy to give a shot at a PR for this, but I'm not quite sure what the implications might be for *not* escaping the newlines (or whatever the workaround might be) so some guidance may be needed.

Thanks a bunch :)
Use filename completer for all shell commands
Previously only :sh used the filename completer

I looked at the PRs that introduced the other shell commands (#1972, #2589, #4931), and didn't see any discussion about completers so I think this is just an oversight. It's possible that I missed a shell command I didn't know about so please check for that in the review.



Discussion / hints:
I would also like to see this, in my usecase it is a spell checking lsp where you can switch the spell checking language via a `SetLanguage` command, but that obviously only works if you can send it the correct language.
What operating system are you on?

When trying to reproduce behavior on Windows I get `asdf,\nasdf` not `asdf,nasdf`.

~~I also grepped for `ShellWords` and nothing came up. What module would this be in?~~ Ah, found it. `Shellwords`, not `ShellWords`.

Made a pretty naive implimentation to escape given sequences. Seems to work fine? I added other a few other common patterns that might crop up for light testing. Not sure if something like this already exists as a helper function somewhere, so just hacked this one together.

```diff
fn yank_joined_impl(editor: &mut Editor, separator: &str, register: char) {
    let (view, doc) = current!(editor);
    let text = doc.text().slice(..);

    let selection = doc.selection(view.id);
    let selections = selection.len();
    let joined = selection
        .fragments(text)
        .fold(String::new(), |mut acc, fragment| {
            if !acc.is_empty() {
-                acc.push_str(separator);
+                acc.push_str(&escape(separator));
            }
            acc.push_str(&fragment);
            acc
        });

    match editor.registers.write(register, vec![joined]) {
        Ok(_) => editor.set_status(format!(
            "joined and yanked {selections} selection{} to register {register}",
            if selections == 1 { "" } else { "s" }
        )),
        Err(err) => editor.set_error(err.to_string()),
    }
}

```

```rust
fn escape(separator: &str) -> Cow<'_, str> {
    enum State {
        Normal,
        Escape,
    }

    let mut escaped = String::new();
    let mut state = State::Normal;
    let mut is_escaped = false;

    for (idx, ch) in separator.char_indices() {
        match state {
            State::Normal => match ch {
                '\\' => {
                    if !is_escaped {
                        // PERF: As not every separator will be escaped, we use `String::new` as that has no initial
                        // allocation. If an escape is found, then we reserve capacity thats the len of the separator
                        // as the new escaped string will be at least that long.
                        escaped.reserve(separator.len());
                        if idx > 0 {
                            // First time finding an escape, so all prior chars can be added to the new escaped version
                            // if its not the very first char found.
                            escaped.push_str(&separator[0..idx]);
                        }
                    }
                    state = State::Escape;
                    is_escaped = true;
                }
                _ => {
                    if is_escaped {
                        escaped.push(ch);
                    }
                }
            },
            State::Escape => match ch {
                'n' => {
                    escaped.push('\n');
                    state = State::Normal;
                }
                't' => {
                    escaped.push('\t');
                    state = State::Normal;
                }
                'r' => {
                    escaped.push('\r');
                    state = State::Normal;
                }
                '\\' => {
                    escaped.push('\\');
                    state = State::Normal;
                }
                _ => {
                    escaped.push('\\');
                    escaped.push(ch);
                    state = State::Normal;
                }
            },
        }
    }

    if is_escaped {
        escaped.into()
    } else {
        separator.into()
    }
}
```

Ah, interesting. I work with a remote setup with helix running in tmux on Ubuntu, but SSHd from MacOS with Alacritty. Thanks for the code, I'll give it a shot and see what I can come up with!
This kind of escaping can expand further than just newlines. Currently you cannot paste in unicode, like 🤩, in the yank-joined command buffer. But by offering a way to escape the literal `\u{1f929}` then you could join with this emoji even if you can't paste it in. Tabs would be another one. Can't tab in the command buffer. You could even add spaces with `\u{20}`, something that has no meaning currently as it gets ignored.

If you don't mind, I'll try opening up a pr that would provide a way to unescape these things in general, and then use it to unescape the separator.


Fix patch:
diff --git a/book/src/SUMMARY.md b/book/src/SUMMARY.md
index e6be2ebc01b7..0aa705675402 100644
--- a/book/src/SUMMARY.md
+++ b/book/src/SUMMARY.md
@@ -12,6 +12,7 @@
   - [Syntax aware motions](./syntax-aware-motions.md)
   - [Pickers](./pickers.md)
   - [Keymap](./keymap.md)
+  - [Command line](./command-line.md)
   - [Commands](./commands.md)
   - [Language support](./lang-support.md)
 - [Migrating from Vim](./from-vim.md)
diff --git a/book/src/command-line.md b/book/src/command-line.md
new file mode 100644
index 000000000000..7bf32bbdadb7
--- /dev/null
+++ b/book/src/command-line.md
@@ -0,0 +1,82 @@
+# Command line
+
+- [Quoting](#quoting)
+- [Flags](#flags)
+- [Expansions](#expansions)
+- [Exceptions](#exceptions)
+
+The command line is used for executing [typable commands](./commands.md#typable-commands) like `:write` or `:quit`. Press `:` to activate the command line.
+
+Typable commands optionally accept arguments. `:write` for example accepts an optional path to write the file contents. The command line also supports a quoting syntax for arguments, flags to modify command behaviors, and _expansions_ - a way to insert values from the editor. Most commands support these features but some have custom parsing rules (see the [exceptions](#exceptions) below).
+
+## Quoting
+
+By default, command arguments are split on tabs and space characters. `:open README.md CHANGELOG.md` for example should open two files, `README.md` and `CHANGELOG.md`. Arguments that contain spaces can be surrounded in single quotes (`'`) or backticks (`` ` ``) to prevent the space from separating the argument, like `:open 'a b.txt'`.
+
+Double quotes may be used the same way, but double quotes _expand_ their inner content. `:echo "%{cursor_line}"` for example may print `1` because of the expansion for the `cursor_line` variable. `:echo '%{cursor_line}'` though prints `%{cursor_line}` literally: content within single quotes or backticks is interpreted as-is.
+
+On Unix systems the backslash character may be used to escape certain characters depending on where it is used. Within an argument which isn't surround in quotes, the backslash can be used to escape the space or tab characters: `:open a\ b.txt` is equivalent to `:open 'a b.txt'`. The backslash may also be used to escape quote characters (`'`, `` ` ``, `"`) or the percent token (`%`) when used at the beginning of an argument. `:echo \%%sh{foo}` for example prints `%sh{foo}` instead of invoking a `foo` shell command and `:echo \"quote` prints `"quote`. The backslash character is treated literally in any other situation on Unix systems and always on Windows: `:echo \n` always prints `\n`.
+
+## Flags
+
+Command flags are optional switches that can be used to alter the behavior of a command. For example the `:sort` command accepts an optional `--reverse` (or `-r` for short) flag which causes the sort command to reverse the sorting direction. Typing the `-` character shows completions for the current command's flags, if any.
+
+The `--` flag specifies the end of flags. All arguments after `--` are treated as positional arguments: `:open -- -a.txt` opens a file called `-a.txt`.
+
+## Expansions
+
+Expansions are patterns that Helix recognizes and replaces within the command line. Helix recognizes anything starting with a percent token (`%`) as an expansion, for example `%sh{echo hi!}`. Expansions are particularly useful when used in commands like `:echo` or `:noop` for executing simple scripts. For example:
+
+```toml
+[keys.normal]
+# Print the current line's git blame information to the statusline.
+space.B = ":echo %sh{git blame -L %{cursor_line},+1 %{buffer_name}}"
+```
+
+Expansions take the form `%[<kind>]<open><contents><close>`. In `%sh{echo hi!}`, for example, the kind is `sh` - the shell expansion - and the contents are "echo hi!", with `{` and `}` acting as opening and closing delimiters. The following open/close characters are recognized as expansion delimiter pairs: `(`/`)`, `[`/`]`, `{`/`}` and `<`/`>`. Plus the single characters `'`, `"` or `|` may be used instead: `%{cursor_line}` is equivalent to `%<cursor_line>`, `%[cursor_line]` or `%|cursor_line|`.
+
+To escape a percent character instead of treating it as an expansion, use two percent characters consecutively. To execute a shell command like `date -u +'%Y-%m-%d'`, double the percent characters: `:echo %sh{date -u +'%%Y-%%m-%%d'}`.
+
+When no `<kind>` is provided, Helix will expand a **variable**. For example `%{cursor_line}` can be used as in argument to insert the line number. `:echo %{cursor_line}` for instance may print `1` to the statusline.
+
+The following variables are supported:
+
+| Name | Description |
+|---   |---          |
+| `cursor_line` | The line number of the primary cursor in the currently focused document, starting at 1. |
+| `cursor_column` | The column number of the primary cursor in the currently focused document, starting at 1. This is counted as the number of grapheme clusters from the start of the line rather than bytes or codepoints. |
+| `buffer_name` | The relative path of the currently focused document. `[scratch]` is expanded instead for scratch buffers. |
+| `line_ending` | A string containing the line ending of the currently focused document. For example on Unix systems this is usually a line-feed character (`\n`) but on Windows systems this may be a carriage-return plus a line-feed (`\r\n`). The line ending kind of the currently focused document can be inspected with the `:line-ending` command. |
+
+Aside from editor variables, the following expansions may be used:
+
+* Unicode `%u{..}`. The contents may contain up to six hexadecimal numbers corresponding to a Unicode codepoint value. For example `:echo %u{25CF}` prints `●` to the statusline.
+* Shell `%sh{..}`. The contents are passed to the configured shell command. For example `:echo %sh{echo "20 * 5" | bc}` may print `100` on the statusline on when using a shell with `echo` and the `bc` calculator installed. Shell expansions are evaluated recursively. `%sh{echo '%{buffer_name}:%{cursor_line}'}` for example executes a command like `echo 'README.md:1'`: the variables within the `%sh{..}` expansion are evaluated before executing the shell command.
+
+As mentioned above, double quotes can be used to surround arguments containing spaces but also support expansions within the quoted content unlike singe quotes or backticks. For example `:echo "circle: %u{25CF}"` prints `circle: ●` to the statusline while `:echo 'circle: %u{25CF}'` prints `circle: %u{25CF}`.
+
+Note that expansions are only evaluated once the Enter key is pressed in command mode.
+
+## Exceptions
+
+The following commands support expansions but otherwise pass the given argument directly to the shell program without interpreting quotes:
+
+* `:insert-output`
+* `:append-output`
+* `:pipe`
+* `:pipe-to`
+* `:run-shell-command`
+
+For example executing `:sh echo "%{buffer_name}:%{cursor_column}"` would pass text like `echo "README.md:1"` as an argument to the shell program: the expansions are evaluated but not the quotes. As mentioned above, percent characters can be used in shell commands by doubling the percent character. To insert the output of a command like `date -u +'%Y-%m-%d'` use `:insert-output date -u +'%%Y-%%m-%%d'`.
+
+The `:set-option` and `:toggle-option` commands use regular parsing for the first argument - the config option name - and parse the rest depending on the config option's type. `:set-option` interprets the second argument as a string for string config options and parses everything else as JSON.
+
+`:toggle-option`'s behavior depends on the JSON type of the config option supplied as the first argument:
+
+* Booleans: only the config option name should be provided. For example `:toggle-option auto-format` will flip the `auto-format` option.
+* Strings: the rest of the command line is parsed with regular quoting rules. For example `:toggle-option indent-heuristic hybrid tree-sitter simple` cycles through "hybrid", "tree-sitter" and "simple" values on each invocation of the command.
+* Numbers, arrays and objects: the rest of the command line is parsed as a stream of JSON values. For example `:toggle-option rulers [81] [51, 73]` cycles through `[81]` and `[51, 73]`.
+
+When providing multiple values to `:toggle-option` there should be no duplicates. `:toggle-option indent-heuristic hybrid simple tree-sitter simple` for example would only toggle between "hybrid" and "tree-sitter" values.
+
+`:lsp-workspace-command` works similarly to `:toggle-option`. The first argument (if present) is parsed according to normal rules. The rest of the line is parsed as JSON values. Unlike `:toggle-option`, string arguments for a command must be quoted. For example `:lsp-workspace-command lsp.Command "foo" "bar"`.
diff --git a/book/src/generated/typable-cmd.md b/book/src/generated/typable-cmd.md
index 55820e08bcdb..dc5a6d08ab13 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.<br>For example to disable smart case search, use `:set search.smart-case false`. |
-| `:toggle-option`, `:toggle` | Toggle a boolean config option at runtime.<br>For example to toggle smart case search, use `:toggle search.smart-case`. |
+| `:toggle-option`, `:toggle` | Toggle a config option at runtime.<br>For example to toggle smart case search, use `:toggle search.smart-case`. |
 | `:get-option`, `:get` | Get the current value of a config option. |
 | `:sort` | Sort ranges in selection. |
-| `:rsort` | Sort ranges in selection in reverse order. |
 | `:reflow` | Hard-wrap the current selection of lines to a given width. |
 | `:tree-sitter-subtree`, `:ts-subtree` | Display the smallest tree-sitter subtree that spans the primary selection, primarily for debugging queries. |
 | `:config-reload` | Refresh user config. |
@@ -88,3 +87,5 @@
 | `:move`, `:mv` | Move the current buffer and its corresponding file to a different path |
 | `:yank-diagnostic` | Yank diagnostic(s) under primary cursor to register, or clipboard by default |
 | `:read`, `:r` | Load a file into buffer |
+| `:echo` | Prints the given arguments to the statusline. |
+| `:noop` | Does nothing. |
diff --git a/helix-core/src/command_line.rs b/helix-core/src/command_line.rs
new file mode 100644
index 000000000000..4c762a711343
--- /dev/null
+++ b/helix-core/src/command_line.rs
@@ -0,0 +1,1266 @@
+//! 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 `<ret>` 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<str>` 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, slice, vec};
+
+/// 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<char>,
+    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 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 (with the help of `Signature::DEFAULT`) 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)]
+#[allow(clippy::manual_non_exhaustive)]
+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)`.
+    ///
+    /// The number of positionals is checked when hitting `<ret>` in command mode. If the actual
+    /// number of positionals is outside the declared range then the command is not executed and
+    /// an error is shown instead. For example `:write` accepts zero or one positional arguments
+    /// (`(0, Some(1))`). A command line like `:write a.txt b.txt` is outside the declared range
+    /// and is not accepted.
+    pub positionals: (usize, Option<usize>),
+    /// 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<u8>,
+    /// A set of flags that a command may accept.
+    ///
+    /// See the `Flag` struct for more info.
+    pub flags: &'static [Flag],
+    /// Do not set this field. Use `..Signature::DEFAULT` to construct a `Signature` instead.
+    // This field allows adding new fields later with minimal code changes. This works like a
+    // `#[non_exhaustive]` annotation except that it supports the `..Signature::DEFAULT`
+    // shorthand.
+    pub _dummy: (),
+}
+
+impl Signature {
+    // This allows defining signatures with the `..Signature::DEFAULT` shorthand. The
+    // `positionals` field should always be overwritten.
+    pub const DEFAULT: Self = Self {
+        positionals: (0, None),
+        raw_after: None,
+        flags: &[],
+        _dummy: (),
+    };
+
+    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<usize>,
+        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 } => {
+                write!(f, "expected ")?;
+                let maybe_plural = |n| if n == 1 { "" } else { "s" };
+                match (min, max) {
+                    (0, Some(0)) => write!(f, "no arguments")?,
+                    (min, Some(max)) if min == max => {
+                        write!(f, "exactly {min} argument{}", maybe_plural(*min))?
+                    }
+                    (min, _) if actual < min => {
+                        write!(f, "at least {min} argument{}", maybe_plural(*min))?
+                    }
+                    (_, Some(max)) if actual > max => {
+                        write!(f, "at most {max} argument{}", maybe_plural(*max))?
+                    }
+                    // `actual` must be either less than `min` or greater than `max` for this type
+                    // to be constructed.
+                    _ => unreachable!(),
+                }
+
+                write!(f, ", got {actual}")
+            }
+            Self::UnterminatedToken { token } => {
+                write!(f, "unterminated token {}", token.content)
+            }
+            Self::DuplicatedFlag { flag } => {
+                write!(f, "flag '--{flag}' specified more than once")
+            }
+            Self::UnknownFlag { text } => write!(f, "unknown flag '{text}'"),
+            Self::FlagMissingArgument { flag } => {
+                write!(f, "flag '--{flag}' missing an argument")
+            }
+            Self::MissingExpansionDelimiter { expansion } => {
+                write!(f, "missing a string delimiter after '%{expansion}'")
+            }
+            Self::UnknownExpansion { kind } => {
+                write!(f, "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<Self> {
+        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
+/// `<ret>` 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 `%[<kind>]<open><contents><close>`. 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<Token<'a>> {
+        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<u8> {
+        self.input.as_bytes().get(self.pos).copied()
+    }
+
+    fn peek_byte(&self) -> Option<u8> {
+        self.input.as_bytes().get(self.pos + 1).copied()
+    }
+
+    fn prev_byte(&self) -> Option<u8> {
+        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: u8) -> (Cow<'a, str>, bool) {
+        assert_eq!(self.byte(), Some(quote));
+        self.pos += 1;
+
+        let mut escaped = String::new();
+        while let Some(offset) = self.input[self.pos..].find(quote as char) {
+            let idx = self.pos + offset;
+            if self.input.as_bytes().get(idx + 1) == Some(&quote) {
+                // Treat two quotes in a row as an escape.
+                escaped.push_str(&self.input[self.pos..idx + 1]);
+                // Advance past the escaped quote.
+                self.pos = idx + 2;
+            } 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 + 1;
+                return (quoted, true);
+            }
+        }
+
+        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<Result<Token<'a>, ParseArgsError<'a>>> {
+        assert_eq!(self.byte(), Some(b'%'));
+
+        self.pos += 1;
+        let kind_start = self.pos;
+        self.pos += self.input[self.pos..]
+            .bytes()
+            .take_while(|b| b.is_ascii_lowercase())
+            .count();
+        let kind = &self.input[kind_start..self.pos];
+
+        let (open, close) = match self.byte() {
+            // We support a couple of hard-coded chars only to make sure we can provide more
+            // useful errors and avoid weird behavior in case of typos. These should cover
+            // practical cases.
+            Some(b'(') => (b'(', b')'),
+            Some(b'[') => (b'[', b']'),
+            Some(b'{') => (b'{', b'}'),
+            Some(b'<') => (b'<', b'>'),
+            Some(b'\'') => (b'\'', b'\''),
+            Some(b'\"') => (b'\"', b'\"'),
+            Some(b'|') => (b'|', b'|'),
+            Some(_) | 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,
+                    })
+                });
+            }
+        };
+        // The content start for expansions is the start of the content - after the opening
+        // delimiter grapheme.
+        let content_start = self.pos + 1;
+        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,
+        };
+
+        let (content, is_terminated) = if open == close {
+            self.parse_quoted(open)
+        } else {
+            self.parse_quoted_balanced(open, close)
+        };
+
+        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.byte(), Some(open));
+        self.pos += 1;
+        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<Token<'a>, ParseArgsError<'a>>;
+
+    fn next(&mut self) -> Option<Self::Item> {
+        self.skip_blanks();
+
+        let byte = self.byte()?;
+        match byte {
+            b'"' | b'\'' | b'`' => {
+                let content_start = self.pos + 1;
+                let (content, is_terminated) = self.parse_quoted(byte);
+                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<Flag>),
+    FlagArgument(Flag),
+}
+
+/// A set of arguments provided to a command on the command line.
+///
+/// Regular arguments are called "positional" arguments (or "positionals" for short). Command line
+/// input might also specify "flags" which can modify a command's behavior.
+///
+/// ```rust,ignore
+/// // Say that the command accepts a "bar" flag which doesn't accept an argument itself.
+/// // This input has two positionals, "foo" and "baz" and one flag "--bar".
+/// let args = Args::parse("foo --bar baz", /* .. */);
+/// // `Args` may be treated like a slice to access positionals.
+/// assert_eq!(args.len(), 2);
+/// assert_eq!(&args[0], "foo");
+/// assert_eq!(&args[1], "baz");
+/// // Use `has_flag` or `get_flag` to access flags.
+/// assert!(args.has_flag("bar"));
+/// ```
+///
+/// The `Args` type can be treated mostly the same as a slice when accessing positional arguments.
+/// Common slice methods like `len`, `get`, `first` and `join` only expose positional arguments.
+/// Additionally, common syntax like `for arg in args` or `&args[idx]` is supported for accessing
+/// positional arguments.
+///
+/// To look up flags, use `Args::get_flag` for flags which should accept an argument or
+/// `Args::has_flag` for boolean flags.
+///
+/// The way that `Args` is parsed from the input depends on a command's `Signature`. See the
+/// `Signature` type for more details.
+#[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<Cow<'a, str>>,
+    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<Option<Token<'p>>, 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<M>(
+        line: &'a str,
+        signature: Signature,
+        validate: bool,
+        mut try_map_fn: M,
+    ) -> Result<Self, Box<dyn Error + 'a>>
+    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<Cow<'a, str>, Box<dyn Error>>,
+    {
+        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 == ch.encode_utf8(&mut [0; 4]))
+                })
+            };
+
+            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<Flag> {
+        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)
+    }
+
+    /// Returns an iterator over all positional arguments.
+    pub fn iter(&self) -> slice::Iter<'_, Cow<'_, str>> {
+        self.positionals.iter()
+    }
+
+    /// 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> {
+        debug_assert!(
+            self.signature.flags.iter().any(|flag| flag.name == name),
+            "flag '--{name}' does not belong to the command's signature"
+        );
+        debug_assert!(
+            self.signature
+                .flags
+                .iter()
+                .any(|flag| flag.name == name && flag.completions.is_some()),
+            "Args::get_flag was used for '--{name}' but should only be used for flags with arguments, use Args::has_flag instead"
+        );
+
+        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 {
+        debug_assert!(
+            self.signature.flags.iter().any(|flag| flag.name == name),
+            "flag '--{name}' does not belong to the command's signature"
+        );
+        debug_assert!(
+            self.signature
+                .flags
+                .iter()
+                .any(|flag| flag.name == name && flag.completions.is_none()),
+            "Args::has_flag was used for '--{name}' but should only be used for flags without arguments, use Args::get_flag instead"
+        );
+
+        self.flags.contains_key(name)
+    }
+}
+
+// `arg[n]`
+impl ops::Index<usize> 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<Cow<'a, str>>;
+
+    fn into_iter(self) -> Self::IntoIter {
+        self.positionals.into_iter()
+    }
+}
+
+// `for arg in &args { .. }`
+impl<'i, 'a> IntoIterator for &'i Args<'a> {
+    type Item = &'i Cow<'a, str>;
+    type IntoIter = slice::Iter<'i, Cow<'a, str>>;
+
+    fn into_iter(self) -> Self::IntoIter {
+        self.positionals.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]) {
+        assert!(
+            Tokenizer::new(input, true).collect::<Result<Vec<_>, _>>().is_err(),
+            "`assert_incomplete_tokens` only accepts input that fails validation, consider using `assert_tokens` instead"
+        );
+
+        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 %<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 %"hello world""#, &["echo", "hello world"]);
+        // When invoking a command, double percents 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<Args<'a>, Box<dyn std::error::Error + 'a>> {
+        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 {
+            positionals: (1, Some(1)),
+            raw_after: Some(0),
+            ..Signature::DEFAULT
+        };
+
+        // 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 89c960ed51a4..02107b33fe7c 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 9d873c366c99..000000000000
--- 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<str>) -> Cow<str> {
-    if !input.chars().any(|x| x.is_ascii_whitespace()) {
-        input
-    } else if cfg!(unix) {
-        Cow::Owned(input.chars().fold(String::new(), |mut buf, c| {
-            if c.is_ascii_whitespace() {
-                buf.push('\\');
-            }
-            buf.push(c);
-            buf
-        }))
-    } else {
-        Cow::Owned(format!("\"{}\"", input))
-    }
-}
-
-enum State {
-    OnWhitespace,
-    Unquoted,
-    UnquotedEscaped,
-    Quoted,
-    QuoteEscaped,
-    Dquoted,
-    DquoteEscaped,
-}
-
-pub struct Shellwords<'a> {
-    state: State,
-    /// Shellwords where whitespace and escapes has been resolved.
-    words: Vec<Cow<'a, str>>,
-    /// The parts of the input that are divided into shellwords. This can be
-    /// used to retrieve the original text for a given word by looking up the
-    /// same index in the Vec as the word in `words`.
-    parts: Vec<&'a str>,
-}
-
-impl<'a> From<&'a str> for Shellwords<'a> {
-    fn from(input: &'a str) -> Self {
-        use State::*;
-
-        let mut state = Unquoted;
-        let mut words = Vec::new();
-        let mut parts = Vec::new();
-        let mut escaped = String::with_capacity(input.len());
-
-        let mut part_start = 0;
-        let mut unescaped_start = 0;
-        let mut end = 0;
-
-        for (i, c) in input.char_indices() {
-            state = match state {
-                OnWhitespace => match c {
-                    '"' => {
-                        end = i;
-                        Dquoted
-                    }
-                    '\'' => {
-                        end = i;
-                        Quoted
-                    }
-                    '\\' => {
-                        if cfg!(unix) {
-                            escaped.push_str(&input[unescaped_start..i]);
-                            unescaped_start = i + 1;
-                            UnquotedEscaped
-                        } else {
-                            OnWhitespace
-                        }
-                    }
-                    c if c.is_ascii_whitespace() => {
-                        end = i;
-                        OnWhitespace
-                    }
-                    _ => Unquoted,
-                },
-                Unquoted => match c {
-                    '\\' => {
-                        if cfg!(unix) {
-                            escaped.push_str(&input[unescaped_start..i]);
-                            unescaped_start = i + 1;
-                            UnquotedEscaped
-                        } else {
-                            Unquoted
-                        }
-                    }
-                    c if c.is_ascii_whitespace() => {
-                        end = i;
-                        OnWhitespace
-                    }
-                    _ => Unquoted,
-                },
-                UnquotedEscaped => Unquoted,
-                Quoted => match c {
-                    '\\' => {
-                        if cfg!(unix) {
-                            escaped.push_str(&input[unescaped_start..i]);
-                            unescaped_start = i + 1;
-                            QuoteEscaped
-                        } else {
-                            Quoted
-                        }
-                    }
-                    '\'' => {
-                        end = i;
-                        OnWhitespace
-                    }
-                    _ => Quoted,
-                },
-                QuoteEscaped => Quoted,
-                Dquoted => match c {
-                    '\\' => {
-                        if cfg!(unix) {
-                            escaped.push_str(&input[unescaped_start..i]);
-                            unescaped_start = i + 1;
-                            DquoteEscaped
-                        } else {
-                            Dquoted
-                        }
-                    }
-                    '"' => {
-                        end = i;
-                        OnWhitespace
-                    }
-                    _ => Dquoted,
-                },
-                DquoteEscaped => Dquoted,
-            };
-
-            let c_len = c.len_utf8();
-            if i == input.len() - c_len && end == 0 {
-                end = i + c_len;
-            }
-
-            if end > 0 {
-                let esc_trim = escaped.trim();
-                let inp = &input[unescaped_start..end];
-
-                if !(esc_trim.is_empty() && inp.trim().is_empty()) {
-                    if esc_trim.is_empty() {
-                        words.push(inp.into());
-                        parts.push(inp);
-                    } else {
-                        words.push([escaped, inp.into()].concat().into());
-                        parts.push(&input[part_start..end]);
-                        escaped = "".to_string();
-                    }
-                }
-                unescaped_start = i + 1;
-                part_start = i + 1;
-                end = 0;
-            }
-        }
-
-        debug_assert!(words.len() == parts.len());
-
-        Self {
-            state,
-            words,
-            parts,
-        }
-    }
-}
-
-impl<'a> Shellwords<'a> {
-    /// Checks that the input ends with a whitespace character which is not escaped.
-    ///
-    /// # Examples
-    ///
-    /// ```rust
-    /// use helix_core::shellwords::Shellwords;
-    /// assert_eq!(Shellwords::from(" ").ends_with_whitespace(), true);
-    /// assert_eq!(Shellwords::from(":open ").ends_with_whitespace(), true);
-    /// assert_eq!(Shellwords::from(":open foo.txt ").ends_with_whitespace(), true);
-    /// assert_eq!(Shellwords::from(":open").ends_with_whitespace(), false);
-    /// #[cfg(unix)]
-    /// assert_eq!(Shellwords::from(":open a\\ ").ends_with_whitespace(), false);
-    /// #[cfg(unix)]
-    /// assert_eq!(Shellwords::from(":open a\\ b.txt").ends_with_whitespace(), false);
-    /// ```
-    pub fn ends_with_whitespace(&self) -> bool {
-        matches!(self.state, State::OnWhitespace)
-    }
-
-    /// Returns the list of shellwords calculated from the input string.
-    pub fn words(&self) -> &[Cow<'a, str>] {
-        &self.words
-    }
-
-    /// Returns a list of strings which correspond to [`Self::words`] but represent the original
-    /// text in the input string - including escape characters - without separating whitespace.
-    pub fn parts(&self) -> &[&'a str] {
-        &self.parts
-    }
-}
-
-#[cfg(test)]
-mod test {
-    use super::*;
-
-    #[test]
-    #[cfg(windows)]
-    fn test_normal() {
-        let input = r#":o single_word twó wörds \three\ \"with\ escaping\\"#;
-        let shellwords = Shellwords::from(input);
-        let result = shellwords.words().to_vec();
-        let expected = vec![
-            Cow::from(":o"),
-            Cow::from("single_word"),
-            Cow::from("twó"),
-            Cow::from("wörds"),
-            Cow::from("\\three\\"),
-            Cow::from("\\"),
-            Cow::from("with\\ escaping\\\\"),
-        ];
-        // TODO test is_owned and is_borrowed, once they get stabilized.
-        assert_eq!(expected, result);
-    }
-
-    #[test]
-    #[cfg(unix)]
-    fn test_normal() {
-        let input = r#":o single_word twó wörds \three\ \"with\ escaping\\"#;
-        let shellwords = Shellwords::from(input);
-        let result = shellwords.words().to_vec();
-        let expected = vec![
-            Cow::from(":o"),
-            Cow::from("single_word"),
-            Cow::from("twó"),
-            Cow::from("wörds"),
-            Cow::from(r#"three "with escaping\"#),
-        ];
-        // TODO test is_owned and is_borrowed, once they get stabilized.
-        assert_eq!(expected, result);
-    }
-
-    #[test]
-    #[cfg(unix)]
-    fn test_quoted() {
-        let quoted =
-            r#":o 'single_word' 'twó wörds' '' ' ''\three\' \"with\ escaping\\' 'quote incomplete"#;
-        let shellwords = Shellwords::from(quoted);
-        let result = shellwords.words().to_vec();
-        let expected = vec![
-            Cow::from(":o"),
-            Cow::from("single_word"),
-            Cow::from("twó wörds"),
-            Cow::from(r#"three' "with escaping\"#),
-            Cow::from("quote incomplete"),
-        ];
-        assert_eq!(expected, result);
-    }
-
-    #[test]
-    #[cfg(unix)]
-    fn test_dquoted() {
-        let dquoted = r#":o "single_word" "twó wörds" "" "  ""\three\' \"with\ escaping\\" "dquote incomplete"#;
-        let shellwords = Shellwords::from(dquoted);
-        let result = shellwords.words().to_vec();
-        let expected = vec![
-            Cow::from(":o"),
-            Cow::from("single_word"),
-            Cow::from("twó wörds"),
-            Cow::from(r#"three' "with escaping\"#),
-            Cow::from("dquote incomplete"),
-        ];
-        assert_eq!(expected, result);
-    }
-
-    #[test]
-    #[cfg(unix)]
-    fn test_mixed() {
-        let dquoted = r#":o single_word 'twó wörds' "\three\' \"with\ escaping\\""no space before"'and after' $#%^@ "%^&(%^" ')(*&^%''a\\\\\b' '"#;
-        let shellwords = Shellwords::from(dquoted);
-        let result = shellwords.words().to_vec();
-        let expected = vec![
-            Cow::from(":o"),
-            Cow::from("single_word"),
-            Cow::from("twó wörds"),
-            Cow::from("three' \"with escaping\\"),
-            Cow::from("no space before"),
-            Cow::from("and after"),
-            Cow::from("$#%^@"),
-            Cow::from("%^&(%^"),
-            Cow::from(")(*&^%"),
-            Cow::from(r#"a\\b"#),
-            //last ' just changes to quoted but since we dont have anything after it, it should be ignored
-        ];
-        assert_eq!(expected, result);
-    }
-
-    #[test]
-    fn test_lists() {
-        let input =
-            r#":set statusline.center ["file-type","file-encoding"] '["list", "in", "quotes"]'"#;
-        let shellwords = Shellwords::from(input);
-        let result = shellwords.words().to_vec();
-        let expected = vec![
-            Cow::from(":set"),
-            Cow::from("statusline.center"),
-            Cow::from(r#"["file-type","file-encoding"]"#),
-            Cow::from(r#"["list", "in", "quotes"]"#),
-        ];
-        assert_eq!(expected, result);
-    }
-
-    #[test]
-    #[cfg(unix)]
-    fn test_escaping_unix() {
-        assert_eq!(escape("foobar".into()), Cow::Borrowed("foobar"));
-        assert_eq!(escape("foo bar".into()), Cow::Borrowed("foo\\ bar"));
-        assert_eq!(escape("foo\tbar".into()), Cow::Borrowed("foo\\\tbar"));
-    }
-
-    #[test]
-    #[cfg(windows)]
-    fn test_escaping_windows() {
-        assert_eq!(escape("foobar".into()), Cow::Borrowed("foobar"));
-        assert_eq!(escape("foo bar".into()), Cow::Borrowed("\"foo bar\""));
-    }
-
-    #[test]
-    #[cfg(unix)]
-    fn test_parts() {
-        assert_eq!(Shellwords::from(":o a").parts(), &[":o", "a"]);
-        assert_eq!(Shellwords::from(":o a\\ ").parts(), &[":o", "a\\ "]);
-    }
-
-    #[test]
-    #[cfg(windows)]
-    fn test_parts() {
-        assert_eq!(Shellwords::from(":o a").parts(), &[":o", "a"]);
-        assert_eq!(Shellwords::from(":o a\\ ").parts(), &[":o", "a\\"]);
-    }
-
-    #[test]
-    fn test_multibyte_at_end() {
-        assert_eq!(Shellwords::from("𒀀").parts(), &["𒀀"]);
-        assert_eq!(
-            Shellwords::from(":sh echo 𒀀").parts(),
-            &[":sh", "echo", "𒀀"]
-        );
-        assert_eq!(
-            Shellwords::from(":sh echo 𒀀 hello world𒀀").parts(),
-            &[":sh", "echo", "𒀀", "hello", "world𒀀"]
-        );
-    }
-}
diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs
index 6c221ed32073..a197792eff59 100644
--- a/helix-term/src/commands.rs
+++ b/helix-term/src/commands.rs
@@ -20,7 +20,7 @@ pub use typed::*;
 use helix_core::{
     char_idx_at_visual_offset,
     chars::char_is_word,
-    comment,
+    command_line, comment,
     doc_formatter::TextFormat,
     encoding, find_workspace,
     graphemes::{self, next_grapheme_boundary},
@@ -33,7 +33,7 @@ use helix_core::{
     object, pos_at_coords,
     regex::{self, Regex},
     search::{self, CharMatcher},
-    selection, shellwords, surround,
+    selection, surround,
     syntax::{BlockCommentToken, LanguageServerFeature},
     text_annotations::{Overlay, TextAnnotations},
     textobject,
@@ -58,7 +58,6 @@ use insert::*;
 use movement::Movement;
 
 use crate::{
-    args,
     compositor::{self, Component, Compositor},
     filter_picker_entry,
     job::Callback,
@@ -211,7 +210,7 @@ use helix_view::{align_view, Align};
 pub enum MappableCommand {
     Typable {
         name: String,
-        args: Vec<String>,
+        args: String,
         doc: String,
     },
     Static {
@@ -246,16 +245,19 @@ impl MappableCommand {
     pub fn execute(&self, cx: &mut Context) {
         match &self {
             Self::Typable { name, args, doc: _ } => {
-                let args: Vec<Cow<str>> = args.iter().map(Cow::from).collect();
                 if let Some(command) = typed::TYPABLE_COMMAND_MAP.get(name.as_str()) {
                     let mut cx = compositor::Context {
                         editor: cx.editor,
                         jobs: cx.jobs,
                         scroll: None,
                     };
-                    if let Err(e) = (command.fun)(&mut cx, &args[..], PromptEvent::Validate) {
+                    if let Err(e) =
+                        typed::execute_command(&mut cx, command, args, PromptEvent::Validate)
+                    {
                         cx.editor.set_error(format!("{}", e));
                     }
+                } else {
+                    cx.editor.set_error(format!("no such command: '{name}'"));
                 }
             }
             Self::Static { fun, .. } => (fun)(cx),
@@ -629,13 +631,8 @@ impl std::str::FromStr for MappableCommand {
 
     fn from_str(s: &str) -> Result<Self, Self::Err> {
         if let Some(suffix) = s.strip_prefix(':') {
-            let mut typable_command = suffix.split(' ').map(|arg| arg.trim());
-            let name = typable_command
-                .next()
-                .ok_or_else(|| anyhow!("Expected typable command name"))?;
-            let args = typable_command
-                .map(|s| s.to_owned())
-                .collect::<Vec<String>>();
+            let (name, args, _) = command_line::split(suffix);
+            ensure!(!name.is_empty(), "Expected typable command name");
             typed::TYPABLE_COMMAND_MAP
                 .get(name)
                 .map(|cmd| {
@@ -647,7 +644,7 @@ impl std::str::FromStr for MappableCommand {
                     MappableCommand::Typable {
                         name: cmd.name.to_owned(),
                         doc,
-                        args,
+                        args: args.to_string(),
                     }
                 })
                 .ok_or_else(|| anyhow!("No TypableCommand named '{}'", s))
@@ -3384,7 +3381,7 @@ pub fn command_palette(cx: &mut Context) {
                     .iter()
                     .map(|cmd| MappableCommand::Typable {
                         name: cmd.name.to_owned(),
-                        args: Vec::new(),
+                        args: String::new(),
                         doc: cmd.doc.to_owned(),
                     }),
             );
diff --git a/helix-term/src/commands/typed.rs b/helix-term/src/commands/typed.rs
index 3953457c9cab..1d57930cc70b 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, Token, TokenKind};
 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<str>], 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<str>], 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<str>], event: PromptEvent) ->
     Ok(())
 }
 
-fn force_quit(
-    cx: &mut compositor::Context,
-    args: &[Cow<str>],
-    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<str>], 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<str>]) -> Vec<DocumentId> {
+fn buffer_gather_paths_impl(editor: &mut Editor, args: Args) -> Vec<DocumentId> {
     // 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<str>]) -> Vec<Docum
 
 fn buffer_close(
     cx: &mut compositor::Context,
-    args: &[Cow<str>],
+    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<str>],
+    args: Args,
     event: PromptEvent,
 ) -> anyhow::Result<()> {
     if event != PromptEvent::Validate {
@@ -247,7 +241,7 @@ fn buffer_gather_others_impl(editor: &mut Editor) -> Vec<DocumentId> {
 
 fn buffer_close_others(
     cx: &mut compositor::Context,
-    _args: &[Cow<str>],
+    _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<str>],
+    _args: Args,
     event: PromptEvent,
 ) -> anyhow::Result<()> {
     if event != PromptEvent::Validate {
@@ -277,7 +271,7 @@ fn buffer_gather_all_impl(editor: &mut Editor) -> Vec<DocumentId> {
 
 fn buffer_close_all(
     cx: &mut compositor::Context,
-    _args: &[Cow<str>],
+    _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<str>],
+    _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<str>],
+    _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<str>],
+    _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<str>>,
-    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<str>],
-    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<str>],
-    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<str>],
+    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<str>],
+    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<str>],
-    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<str>],
-    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<str>],
+    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::<u8>()
             .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<str>],
+    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<str>],
-    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<str>],
-    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<str>],
-    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<str>],
+    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
@@ -755,11 +716,7 @@ pub fn write_all_impl(
     Ok(())
 }
 
-fn write_all(
-    cx: &mut compositor::Context,
-    _args: &[Cow<str>],
-    event: PromptEvent,
-) -> anyhow::Result<()> {
+fn write_all(cx: &mut compositor::Context, _args: Args, event: PromptEvent) -> anyhow::Result<()> {
     if event != PromptEvent::Validate {
         return Ok(());
     }
@@ -776,7 +733,7 @@ fn write_all(
 
 fn force_write_all(
     cx: &mut compositor::Context,
-    _args: &[Cow<str>],
+    _args: Args,
     event: PromptEvent,
 ) -> anyhow::Result<()> {
     if event != PromptEvent::Validate {
@@ -795,7 +752,7 @@ fn force_write_all(
 
 fn write_all_quit(
     cx: &mut compositor::Context,
-    _args: &[Cow<str>],
+    _args: Args,
     event: PromptEvent,
 ) -> anyhow::Result<()> {
     if event != PromptEvent::Validate {
@@ -814,7 +771,7 @@ fn write_all_quit(
 
 fn force_write_all_quit(
     cx: &mut compositor::Context,
-    _args: &[Cow<str>],
+    _args: Args,
     event: PromptEvent,
 ) -> anyhow::Result<()> {
     if event != PromptEvent::Validate {
@@ -846,11 +803,7 @@ fn quit_all_impl(cx: &mut compositor::Context, force: bool) -> anyhow::Result<()
     Ok(())
 }
 
-fn quit_all(
-    cx: &mut compositor::Context,
-    _args: &[Cow<str>],
-    event: PromptEvent,
-) -> anyhow::Result<()> {
+fn quit_all(cx: &mut compositor::Context, _args: Args, event: PromptEvent) -> anyhow::Result<()> {
     if event != PromptEvent::Validate {
         return Ok(());
     }
@@ -860,7 +813,7 @@ fn quit_all(
 
 fn force_quit_all(
     cx: &mut compositor::Context,
-    _args: &[Cow<str>],
+    _args: Args,
     event: PromptEvent,
 ) -> anyhow::Result<()> {
     if event != PromptEvent::Validate {
@@ -870,11 +823,7 @@ fn force_quit_all(
     quit_all_impl(cx, true)
 }
 
-fn cquit(
-    cx: &mut compositor::Context,
-    args: &[Cow<str>],
-    event: PromptEvent,
-) -> anyhow::Result<()> {
+fn cquit(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> anyhow::Result<()> {
     if event != PromptEvent::Validate {
         return Ok(());
     }
@@ -888,11 +837,7 @@ fn cquit(
     quit_all_impl(cx, false)
 }
 
-fn force_cquit(
-    cx: &mut compositor::Context,
-    args: &[Cow<str>],
-    event: PromptEvent,
-) -> anyhow::Result<()> {
+fn force_cquit(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> anyhow::Result<()> {
     if event != PromptEvent::Validate {
         return Ok(());
     }
@@ -906,11 +851,7 @@ fn force_cquit(
     quit_all_impl(cx, true)
 }
 
-fn theme(
-    cx: &mut compositor::Context,
-    args: &[Cow<str>],
-    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 => {
@@ -953,7 +894,7 @@ fn theme(
 
 fn yank_main_selection_to_clipboard(
     cx: &mut compositor::Context,
-    _args: &[Cow<str>],
+    _args: Args,
     event: PromptEvent,
 ) -> anyhow::Result<()> {
     if event != PromptEvent::Validate {
@@ -964,17 +905,11 @@ fn yank_main_selection_to_clipboard(
     Ok(())
 }
 
-fn yank_joined(
-    cx: &mut compositor::Context,
-    args: &[Cow<str>],
-    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);
@@ -988,7 +923,7 @@ fn yank_joined(
 
 fn yank_joined_to_clipboard(
     cx: &mut compositor::Context,
-    args: &[Cow<str>],
+    args: Args,
     event: PromptEvent,
 ) -> anyhow::Result<()> {
     if event != PromptEvent::Validate {
@@ -1004,7 +939,7 @@ fn yank_joined_to_clipboard(
 
 fn yank_main_selection_to_primary_clipboard(
     cx: &mut compositor::Context,
-    _args: &[Cow<str>],
+    _args: Args,
     event: PromptEvent,
 ) -> anyhow::Result<()> {
     if event != PromptEvent::Validate {
@@ -1017,7 +952,7 @@ fn yank_main_selection_to_primary_clipboard(
 
 fn yank_joined_to_primary_clipboard(
     cx: &mut compositor::Context,
-    args: &[Cow<str>],
+    args: Args,
     event: PromptEvent,
 ) -> anyhow::Result<()> {
     if event != PromptEvent::Validate {
@@ -1033,7 +968,7 @@ fn yank_joined_to_primary_clipboard(
 
 fn paste_clipboard_after(
     cx: &mut compositor::Context,
-    _args: &[Cow<str>],
+    _args: Args,
     event: PromptEvent,
 ) -> anyhow::Result<()> {
     if event != PromptEvent::Validate {
@@ -1046,7 +981,7 @@ fn paste_clipboard_after(
 
 fn paste_clipboard_before(
     cx: &mut compositor::Context,
-    _args: &[Cow<str>],
+    _args: Args,
     event: PromptEvent,
 ) -> anyhow::Result<()> {
     if event != PromptEvent::Validate {
@@ -1059,7 +994,7 @@ fn paste_clipboard_before(
 
 fn paste_primary_clipboard_after(
     cx: &mut compositor::Context,
-    _args: &[Cow<str>],
+    _args: Args,
     event: PromptEvent,
 ) -> anyhow::Result<()> {
     if event != PromptEvent::Validate {
@@ -1072,7 +1007,7 @@ fn paste_primary_clipboard_after(
 
 fn paste_primary_clipboard_before(
     cx: &mut compositor::Context,
-    _args: &[Cow<str>],
+    _args: Args,
     event: PromptEvent,
 ) -> anyhow::Result<()> {
     if event != PromptEvent::Validate {
@@ -1085,7 +1020,7 @@ fn paste_primary_clipboard_before(
 
 fn replace_selections_with_clipboard(
     cx: &mut compositor::Context,
-    _args: &[Cow<str>],
+    _args: Args,
     event: PromptEvent,
 ) -> anyhow::Result<()> {
     if event != PromptEvent::Validate {
@@ -1098,7 +1033,7 @@ fn replace_selections_with_clipboard(
 
 fn replace_selections_with_primary_clipboard(
     cx: &mut compositor::Context,
-    _args: &[Cow<str>],
+    _args: Args,
     event: PromptEvent,
 ) -> anyhow::Result<()> {
     if event != PromptEvent::Validate {
@@ -1111,7 +1046,7 @@ fn replace_selections_with_primary_clipboard(
 
 fn show_clipboard_provider(
     cx: &mut compositor::Context,
-    _args: &[Cow<str>],
+    _args: Args,
     event: PromptEvent,
 ) -> anyhow::Result<()> {
     if event != PromptEvent::Validate {
@@ -1125,7 +1060,7 @@ fn show_clipboard_provider(
 
 fn change_current_directory(
     cx: &mut compositor::Context,
-    args: &[Cow<str>],
+    args: Args,
     event: PromptEvent,
 ) -> anyhow::Result<()> {
     if event != PromptEvent::Validate {
@@ -1159,7 +1094,7 @@ fn change_current_directory(
 
 fn show_current_directory(
     cx: &mut compositor::Context,
-    _args: &[Cow<str>],
+    _args: Args,
     event: PromptEvent,
 ) -> anyhow::Result<()> {
     if event != PromptEvent::Validate {
@@ -1180,7 +1115,7 @@ fn show_current_directory(
 /// Sets the [`Document`]'s encoding..
 fn set_encoding(
     cx: &mut compositor::Context,
-    args: &[Cow<str>],
+    args: Args,
     event: PromptEvent,
 ) -> anyhow::Result<()> {
     if event != PromptEvent::Validate {
@@ -1200,7 +1135,7 @@ fn set_encoding(
 /// Shows info about the character under the primary cursor.
 fn get_character_info(
     cx: &mut compositor::Context,
-    _args: &[Cow<str>],
+    _args: Args,
     event: PromptEvent,
 ) -> anyhow::Result<()> {
     if event != PromptEvent::Validate {
@@ -1323,11 +1258,7 @@ fn get_character_info(
 }
 
 /// Reload the [`Document`] from its source file.
-fn reload(
-    cx: &mut compositor::Context,
-    _args: &[Cow<str>],
-    event: PromptEvent,
-) -> anyhow::Result<()> {
+fn reload(cx: &mut compositor::Context, _args: Args, event: PromptEvent) -> anyhow::Result<()> {
     if event != PromptEvent::Validate {
         return Ok(());
     }
@@ -1346,11 +1277,7 @@ fn reload(
     Ok(())
 }
 
-fn reload_all(
-    cx: &mut compositor::Context,
-    _args: &[Cow<str>],
-    event: PromptEvent,
-) -> anyhow::Result<()> {
+fn reload_all(cx: &mut compositor::Context, _args: Args, event: PromptEvent) -> anyhow::Result<()> {
     if event != PromptEvent::Validate {
         return Ok(());
     }
@@ -1406,11 +1333,7 @@ fn reload_all(
 }
 
 /// Update the [`Document`] if it has been modified.
-fn update(
-    cx: &mut compositor::Context,
-    args: &[Cow<str>],
-    event: PromptEvent,
-) -> anyhow::Result<()> {
+fn update(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> anyhow::Result<()> {
     if event != PromptEvent::Validate {
         return Ok(());
     }
@@ -1425,7 +1348,7 @@ fn update(
 
 fn lsp_workspace_command(
     cx: &mut compositor::Context,
-    args: &[Cow<str>],
+    args: Args,
     event: PromptEvent,
 ) -> anyhow::Result<()> {
     if event != PromptEvent::Validate {
@@ -1481,19 +1404,30 @@ fn lsp_workspace_command(
         };
         cx.jobs.callback(callback);
     } else {
-        let command = args.join(" ");
+        let command = args[0].to_string();
         let matches: Vec<_> = ls_id_commands
             .filter(|(_ls_id, c)| *c == &command)
             .collect();
 
         match matches.as_slice() {
             [(ls_id, _command)] => {
+                let arguments = args
+                    .get(1)
+                    .map(|rest| {
+                        serde_json::Deserializer::from_str(rest)
+                            .into_iter()
+                            .collect::<Result<Vec<Value>, _>>()
+                            .map_err(|err| anyhow!("failed to parse arguments: {err}"))
+                    })
+                    .transpose()?
+                    .filter(|args| !args.is_empty());
+
                 execute_lsp_command(
                     cx.editor,
                     *ls_id,
                     helix_lsp::lsp::Command {
                         title: command.clone(),
-                        arguments: None,
+                        arguments,
                         command,
                     },
                 );
@@ -1513,11 +1447,7 @@ fn lsp_workspace_command(
     Ok(())
 }
 
-fn lsp_restart(
-    cx: &mut compositor::Context,
-    args: &[Cow<str>],
-    event: PromptEvent,
-) -> anyhow::Result<()> {
+fn lsp_restart(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> anyhow::Result<()> {
     if event != PromptEvent::Validate {
         return Ok(());
     }
@@ -1602,11 +1532,7 @@ fn lsp_restart(
     }
 }
 
-fn lsp_stop(
-    cx: &mut compositor::Context,
-    args: &[Cow<str>],
-    event: PromptEvent,
-) -> anyhow::Result<()> {
+fn lsp_stop(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> anyhow::Result<()> {
     if event != PromptEvent::Validate {
         return Ok(());
     }
@@ -1647,7 +1573,7 @@ fn lsp_stop(
 
 fn tree_sitter_scopes(
     cx: &mut compositor::Context,
-    _args: &[Cow<str>],
+    _args: Args,
     event: PromptEvent,
 ) -> anyhow::Result<()> {
     if event != PromptEvent::Validate {
@@ -1680,7 +1606,7 @@ fn tree_sitter_scopes(
 
 fn tree_sitter_highlight_name(
     cx: &mut compositor::Context,
-    _args: &[Cow<str>],
+    _args: Args,
     event: PromptEvent,
 ) -> anyhow::Result<()> {
     fn find_highlight_at_cursor(
@@ -1753,11 +1679,7 @@ fn tree_sitter_highlight_name(
     Ok(())
 }
 
-fn vsplit(
-    cx: &mut compositor::Context,
-    args: &[Cow<str>],
-    event: PromptEvent,
-) -> anyhow::Result<()> {
+fn vsplit(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> anyhow::Result<()> {
     if event != PromptEvent::Validate {
         return Ok(());
     }
@@ -1774,11 +1696,7 @@ fn vsplit(
     Ok(())
 }
 
-fn hsplit(
-    cx: &mut compositor::Context,
-    args: &[Cow<str>],
-    event: PromptEvent,
-) -> anyhow::Result<()> {
+fn hsplit(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> anyhow::Result<()> {
     if event != PromptEvent::Validate {
         return Ok(());
     }
@@ -1795,11 +1713,7 @@ fn hsplit(
     Ok(())
 }
 
-fn vsplit_new(
-    cx: &mut compositor::Context,
-    _args: &[Cow<str>],
-    event: PromptEvent,
-) -> anyhow::Result<()> {
+fn vsplit_new(cx: &mut compositor::Context, _args: Args, event: PromptEvent) -> anyhow::Result<()> {
     if event != PromptEvent::Validate {
         return Ok(());
     }
@@ -1809,11 +1723,7 @@ fn vsplit_new(
     Ok(())
 }
 
-fn hsplit_new(
-    cx: &mut compositor::Context,
-    _args: &[Cow<str>],
-    event: PromptEvent,
-) -> anyhow::Result<()> {
+fn hsplit_new(cx: &mut compositor::Context, _args: Args, event: PromptEvent) -> anyhow::Result<()> {
     if event != PromptEvent::Validate {
         return Ok(());
     }
@@ -1823,11 +1733,7 @@ fn hsplit_new(
     Ok(())
 }
 
-fn debug_eval(
-    cx: &mut compositor::Context,
-    args: &[Cow<str>],
-    event: PromptEvent,
-) -> anyhow::Result<()> {
+fn debug_eval(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> anyhow::Result<()> {
     if event != PromptEvent::Validate {
         return Ok(());
     }
@@ -1849,16 +1755,12 @@ fn debug_eval(
     Ok(())
 }
 
-fn debug_start(
-    cx: &mut compositor::Context,
-    args: &[Cow<str>],
-    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)),
@@ -1868,14 +1770,14 @@ fn debug_start(
 
 fn debug_remote(
     cx: &mut compositor::Context,
-    args: &[Cow<str>],
+    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()?),
@@ -1887,11 +1789,7 @@ fn debug_remote(
     dap_start_impl(cx, name.as_deref(), address, Some(args))
 }
 
-fn tutor(
-    cx: &mut compositor::Context,
-    _args: &[Cow<str>],
-    event: PromptEvent,
-) -> anyhow::Result<()> {
+fn tutor(cx: &mut compositor::Context, _args: Args, event: PromptEvent) -> anyhow::Result<()> {
     if event != PromptEvent::Validate {
         return Ok(());
     }
@@ -1913,10 +1811,7 @@ fn abort_goto_line_number_preview(cx: &mut compositor::Context) {
     }
 }
 
-fn update_goto_line_number_preview(
-    cx: &mut compositor::Context,
-    args: &[Cow<str>],
-) -> 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()
@@ -1934,14 +1829,12 @@ fn update_goto_line_number_preview(
 
 pub(super) fn goto_line_number(
     cx: &mut compositor::Context,
-    args: &[Cow<str>],
+    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.
@@ -1968,19 +1861,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<str>],
-    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);
 
@@ -1994,19 +1879,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<str>],
-    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);
@@ -2035,16 +1913,13 @@ fn set_option(
 /// case`.
 fn toggle_option(
     cx: &mut compositor::Context,
-    args: &[Cow<str>],
+    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);
@@ -2057,48 +1932,69 @@ fn toggle_option(
         Value::Bool(ref value) => {
             ensure!(
                 args.len() == 1,
-                "Bad arguments. For boolean configurations use: `:toggle key`"
+                "Bad arguments. For boolean configurations use: `:toggle {key}`"
             );
             Value::Bool(!value)
         }
         Value::String(ref value) => {
             ensure!(
-                args.len() > 2,
-                "Bad arguments. For string configurations use: `:toggle key val1 val2 ...`",
+                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::<Result<_, _>>()
+                .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 => bail!("Configuration {key} cannot be toggled"),
+        Value::Number(_) | Value::Array(_) | Value::Object(_) => {
             ensure!(
-                args.len() > 2,
-                "Bad arguments. For number configurations use: `:toggle key val1 val2 ...`",
+                args.len() == 2,
+                "Bad arguments. For {kind} configurations use: `:toggle {key} val1 val2 ...`",
+                kind = match value {
+                    Value::Number(_) => "number",
+                    Value::Array(_) => "array",
+                    Value::Object(_) => "object",
+                    _ => unreachable!(),
+                }
             );
+            // For numbers, arrays and objects, parse each argument with
+            // `serde_json::StreamDeserializer`.
+            let values: Vec<Value> = serde_json::Deserializer::from_str(&args[1])
+                .into_iter()
+                .collect::<Result<_, _>>()
+                .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
@@ -2109,11 +2005,7 @@ fn toggle_option(
 }
 
 /// Change the language of the current buffer at runtime.
-fn language(
-    cx: &mut compositor::Context,
-    args: &[Cow<str>],
-    event: PromptEvent,
-) -> anyhow::Result<()> {
+fn language(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> anyhow::Result<()> {
     if event != PromptEvent::Validate {
         return Ok(());
     }
@@ -2125,13 +2017,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())?;
@@ -2147,31 +2035,15 @@ fn language(
     Ok(())
 }
 
-fn sort(cx: &mut compositor::Context, args: &[Cow<str>], 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<str>],
-    event: PromptEvent,
-) -> anyhow::Result<()> {
-    if event != PromptEvent::Validate {
-        return Ok(());
-    }
-
-    sort_impl(cx, args, true)
-}
-
-fn sort_impl(
-    cx: &mut compositor::Context,
-    _args: &[Cow<str>],
-    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(..);
@@ -2207,11 +2079,7 @@ fn sort_impl(
     Ok(())
 }
 
-fn reflow(
-    cx: &mut compositor::Context,
-    args: &[Cow<str>],
-    event: PromptEvent,
-) -> anyhow::Result<()> {
+fn reflow(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> anyhow::Result<()> {
     if event != PromptEvent::Validate {
         return Ok(());
     }
@@ -2250,7 +2118,7 @@ fn reflow(
 
 fn tree_sitter_subtree(
     cx: &mut compositor::Context,
-    _args: &[Cow<str>],
+    _args: Args,
     event: PromptEvent,
 ) -> anyhow::Result<()> {
     if event != PromptEvent::Validate {
@@ -2289,7 +2157,7 @@ fn tree_sitter_subtree(
 
 fn open_config(
     cx: &mut compositor::Context,
-    _args: &[Cow<str>],
+    _args: Args,
     event: PromptEvent,
 ) -> anyhow::Result<()> {
     if event != PromptEvent::Validate {
@@ -2303,7 +2171,7 @@ fn open_config(
 
 fn open_workspace_config(
     cx: &mut compositor::Context,
-    _args: &[Cow<str>],
+    _args: Args,
     event: PromptEvent,
 ) -> anyhow::Result<()> {
     if event != PromptEvent::Validate {
@@ -2315,11 +2183,7 @@ fn open_workspace_config(
     Ok(())
 }
 
-fn open_log(
-    cx: &mut compositor::Context,
-    _args: &[Cow<str>],
-    event: PromptEvent,
-) -> anyhow::Result<()> {
+fn open_log(cx: &mut compositor::Context, _args: Args, event: PromptEvent) -> anyhow::Result<()> {
     if event != PromptEvent::Validate {
         return Ok(());
     }
@@ -2330,7 +2194,7 @@ fn open_log(
 
 fn refresh_config(
     cx: &mut compositor::Context,
-    _args: &[Cow<str>],
+    _args: Args,
     event: PromptEvent,
 ) -> anyhow::Result<()> {
     if event != PromptEvent::Validate {
@@ -2343,47 +2207,41 @@ fn refresh_config(
 
 fn append_output(
     cx: &mut compositor::Context,
-    args: &[Cow<str>],
+    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);
     Ok(())
 }
 
 fn insert_output(
     cx: &mut compositor::Context,
-    args: &[Cow<str>],
+    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);
     Ok(())
 }
 
-fn pipe_to(
-    cx: &mut compositor::Context,
-    args: &[Cow<str>],
-    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<str>], 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<str>],
+    args: Args,
     event: PromptEvent,
     behavior: &ShellBehavior,
 ) -> anyhow::Result<()> {
@@ -2391,14 +2249,13 @@ fn pipe_impl(
         return Ok(());
     }
 
-    ensure!(!args.is_empty(), "Shell command required");
     shell(cx, &args.join(" "), behavior);
     Ok(())
 }
 
 fn run_shell_command(
     cx: &mut compositor::Context,
-    args: &[Cow<str>],
+    args: Args,
     event: PromptEvent,
 ) -> anyhow::Result<()> {
     if event != PromptEvent::Validate {
@@ -2434,13 +2291,12 @@ fn run_shell_command(
 
 fn reset_diff_change(
     cx: &mut compositor::Context,
-    args: &[Cow<str>],
+    _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;
@@ -2487,14 +2343,13 @@ fn reset_diff_change(
 
 fn clear_register(
     cx: &mut compositor::Context,
-    args: &[Cow<str>],
+    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");
@@ -2503,7 +2358,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) {
@@ -2516,11 +2371,7 @@ fn clear_register(
     Ok(())
 }
 
-fn redraw(
-    cx: &mut compositor::Context,
-    _args: &[Cow<str>],
-    event: PromptEvent,
-) -> anyhow::Result<()> {
+fn redraw(cx: &mut compositor::Context, _args: Args, event: PromptEvent) -> anyhow::Result<()> {
     if event != PromptEvent::Validate {
         return Ok(());
     }
@@ -2539,16 +2390,11 @@ fn redraw(
     Ok(())
 }
 
-fn move_buffer(
-    cx: &mut compositor::Context,
-    args: &[Cow<str>],
-    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()
@@ -2563,7 +2409,7 @@ fn move_buffer(
 
 fn yank_diagnostic(
     cx: &mut compositor::Context,
-    args: &[Cow<str>],
+    args: Args,
     event: PromptEvent,
 ) -> anyhow::Result<()> {
     if event != PromptEvent::Validate {
@@ -2601,7 +2447,7 @@ fn yank_diagnostic(
     Ok(())
 }
 
-fn read(cx: &mut compositor::Context, args: &[Cow<str>], event: PromptEvent) -> anyhow::Result<()> {
+fn read(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> anyhow::Result<()> {
     if event != PromptEvent::Validate {
         return Ok(());
     }
@@ -2609,9 +2455,6 @@ fn read(cx: &mut compositor::Context, args: &[Cow<str>], 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()));
 
@@ -2635,132 +2478,239 @@ fn read(cx: &mut compositor::Context, args: &[Cow<str>], 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(())
+}
+
+/// This command handles all of its input as-is with no quoting or flags.
+const SHELL_SIGNATURE: Signature = Signature {
+    positionals: (1, Some(2)),
+    raw_after: Some(1),
+    ..Signature::DEFAULT
+};
+
+const SHELL_COMPLETER: CommandCompleter = CommandCompleter::positional(&[
+    // Command name (TODO: consider a command completer - Kakoune has prior art)
+    completers::none,
+    // Shell argument(s)
+    completers::filename,
+]);
+
 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",
@@ -2770,336 +2720,529 @@ 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, None),
+            raw_after: Some(1),
+            ..Signature::DEFAULT
+        },
     },
     TypableCommand {
         name: "lsp-restart",
         aliases: &[],
         doc: "Restarts the given language servers, or all language servers that are used by the current file if no arguments are supplied",
         fun: lsp_restart,
-        signature: CommandSignature::all(completers::configured_language_servers),
+        completer: CommandCompleter::all(completers::configured_language_servers),
+        signature: Signature {
+            positionals: (0, None),
+            ..Signature::DEFAULT
+        },
     },
     TypableCommand {
         name: "lsp-stop",
         aliases: &[],
         doc: "Stops the given language servers, or all language servers that are used by the current file if no arguments are supplied",
         fun: lsp_stop,
-        signature: CommandSignature::all(completers::active_language_servers),
+        completer: CommandCompleter::all(completers::active_language_servers),
+        signature: Signature {
+            positionals: (0, None),
+            ..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",
@@ -3107,154 +3250,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: SHELL_COMPLETER,
+        signature: SHELL_SIGNATURE,
     },
     TypableCommand {
         name: "append-output",
         aliases: &[],
         doc: "Run shell command, appending output after each selection.",
         fun: append_output,
-        signature: CommandSignature::none(),
+        completer: SHELL_COMPLETER,
+        signature: SHELL_SIGNATURE,
     },
     TypableCommand {
         name: "pipe",
         aliases: &[],
         doc: "Pipe each selection to the shell command.",
         fun: pipe,
-        signature: CommandSignature::none(),
+        completer: SHELL_COMPLETER,
+        signature: SHELL_SIGNATURE,
     },
     TypableCommand {
         name: "pipe-to",
         aliases: &[],
         doc: "Pipe each selection to the shell command, ignoring output.",
         fun: pipe_to,
-        signature: CommandSignature::none(),
+        completer: SHELL_COMPLETER,
+        signature: SHELL_SIGNATURE,
     },
     TypableCommand {
         name: "run-shell-command",
         aliases: &["sh"],
         doc: "Run a shell command",
         fun: run_shell_command,
-        signature: CommandSignature::all(completers::filename)
+        completer: SHELL_COMPLETER,
+        signature: SHELL_SIGNATURE,
     },
     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
+        },
     },
 ];
 
@@ -3269,125 +3506,397 @@ pub static TYPABLE_COMMAND_MAP: Lazy<HashMap<&'static str, &'static TypableComma
             .collect()
     });
 
+fn execute_command_line(
+    cx: &mut compositor::Context,
+    input: &str,
+    event: PromptEvent,
+) -> 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::<usize>().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<Cow<str>> {
+    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 = " <arg>";
+
+        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
+        }
+
+        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();
+        }
+    }
 
-                let argument_number = argument_number_of(&shellwords);
+    Some(Cow::Owned(doc))
+}
 
-                if let Some(completer) = TYPABLE_COMMAND_MAP
-                    .get(&words[0] as &str)
-                    .map(|tc| tc.completer_for_argument_number(argument_number))
-                {
-                    completer(editor, word)
+fn complete_command_line(editor: &Editor, input: &str) -> Vec<ui::prompt::Completion> {
+    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<ui::prompt::Completion> {
+    use command_line::{CompletionState, ExpansionKind, 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::<Vec<&str>>();
-            if parts.is_empty() {
-                return;
-            }
+        }
+        TokenKind::Expand | TokenKind::Expansion(ExpansionKind::Shell) => {
+            // See the comment about the checked sub expect above.
+            let arg_completer = matches!(args.completion_state(), CompletionState::Positional)
+                .then(|| {
+                    let n = args
+                        .len()
+                        .checked_sub(1)
+                        .expect("completion state to be positional");
+                    command.completer_for_argument_number(n)
+                });
+            complete_expand(editor, &token, arg_completer, 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::<usize>().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: &Token,
+    range: ops::RangeFrom<usize>,
+    mut span: Span<'a>,
+    offset: usize,
+) -> (ops::RangeFrom<usize>, Span<'a>) {
+    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, '\'', "''")
+            ));
+            // Ignore `range.start` here since we're replacing the entire token.
+            ((offset + token.content_start).., span)
+        }
+        TokenKind::Quoted(quote) => {
+            span.content = replace(span.content, quote.char(), quote.escape());
+            ((range.start + offset + token.content_start).., span)
+        }
+        TokenKind::Expand => {
+            // NOTE: `token.content_start` is already accounted for in `offset` for `Expand`
+            // tokens.
+            span.content = replace(span.content, '"', "\"\"");
+            ((range.start + offset).., span)
+        }
+        _ => ((range.start + offset + token.content_start).., span),
+    }
+}
 
-                if let Err(e) = (cmd.fun)(cx, &args[1..], event) {
-                    cx.editor.set_error(format!("{}", e));
-                }
-            } 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();
+fn complete_expand(
+    editor: &Editor,
+    token: &Token,
+    completer: Option<&Completer>,
+    offset: usize,
+) -> Vec<ui::prompt::Completion> {
+    use command_line::{ExpansionKind, Tokenizer};
 
-        if let Some(typed::TypableCommand { doc, aliases, .. }) =
-            typed::TYPABLE_COMMAND_MAP.get(part)
-        {
-            if aliases.is_empty() {
-                return Some((*doc).into());
+    let mut start = 0;
+
+    // If the expand token contains expansions, complete those.
+    while let Some(idx) = token.content[start..].find('%') {
+        let idx = start + idx;
+        if token.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(&token.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);
+                }
+                TokenKind::Expansion(ExpansionKind::Shell) => {
+                    return complete_expand(editor, &token, None, 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));
+    match completer {
+        // If no expansions were found and an argument is being completed,
+        Some(completer) if start == 0 => completer(editor, &token.content)
+            .into_iter()
+            .map(|(range, span)| quote_completion(token, range, span, offset))
+            .collect(),
+        _ => 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<ui::prompt::Completion> {
+    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<ui::prompt::Completion> {
+    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 2385d460d53d..d8227b500ee7 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-view/src/expansion.rs b/helix-view/src/expansion.rs
new file mode 100644
index 000000000000..96a71b8e5c86
--- /dev/null
+++ b/helix-view/src/expansion.rs
@@ -0,0 +1,219 @@
+use std::borrow::Cow;
+
+use helix_core::command_line::{ExpansionKind, Token, TokenKind, Tokenizer};
+
+use anyhow::{anyhow, bail, Result};
+
+use crate::Editor;
+
+/// Variables that can be expanded in the command mode (`:`) via the expansion syntax.
+///
+/// For example `%{cursor_line}`.
+//
+// To add a new variable follow these steps:
+//
+// * Add the new enum member to `Variable` below.
+// * Add an item to the `VARIANTS` constant - this enables completion.
+// * Add a branch in `Variable::as_str`, converting the name from TitleCase to snake_case.
+// * Add a branch in `Variable::from_name` with the reverse association.
+// * Add a branch in the `expand_variable` function to read the value from the editor.
+// * Add the new variable to the documentation in `book/src/command-line.md`.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Variable {
+    /// The one-indexed line number of the primary cursor in the currently focused document.
+    CursorLine,
+    /// The one-indexed column number of the primary cursor in the currently focused document.
+    ///
+    /// Note that this is the count of grapheme clusters from the start of the line (regardless of
+    /// softwrap) - the same as the `position` element in the statusline.
+    CursorColumn,
+    /// The display name of the currently focused document.
+    ///
+    /// This corresponds to `crate::Document::display_name`.
+    BufferName,
+    /// A string containing the line-ending of the currently focused document.
+    LineEnding,
+}
+
+impl Variable {
+    pub const VARIANTS: &'static [Self] = &[
+        Self::CursorLine,
+        Self::CursorColumn,
+        Self::BufferName,
+        Self::LineEnding,
+    ];
+
+    pub const fn as_str(&self) -> &'static str {
+        match self {
+            Self::CursorLine => "cursor_line",
+            Self::CursorColumn => "cursor_column",
+            Self::BufferName => "buffer_name",
+            Self::LineEnding => "line_ending",
+        }
+    }
+
+    pub fn from_name(s: &str) -> Option<Self> {
+        match s {
+            "cursor_line" => Some(Self::CursorLine),
+            "cursor_column" => Some(Self::CursorColumn),
+            "buffer_name" => Some(Self::BufferName),
+            "line_ending" => Some(Self::LineEnding),
+            _ => None,
+        }
+    }
+}
+
+/// Expands the given command line token.
+///
+/// Note that the lifetime of the expanded variable is only bound to the input token and not the
+/// `Editor`. See `expand_variable` below for more discussion of lifetimes.
+pub fn expand<'a>(editor: &Editor, token: Token<'a>) -> Result<Cow<'a, str>> {
+    // Note: see the `TokenKind` documentation for more details on how each branch should expand.
+    match token.kind {
+        TokenKind::Unquoted | TokenKind::Quoted(_) => Ok(token.content),
+        TokenKind::Expansion(ExpansionKind::Variable) => {
+            let var = Variable::from_name(&token.content)
+                .ok_or_else(|| anyhow!("unknown variable '{}'", token.content))?;
+
+            expand_variable(editor, var)
+        }
+        TokenKind::Expansion(ExpansionKind::Unicode) => {
+            if let Some(ch) = u32::from_str_radix(token.content.as_ref(), 16)
+                .ok()
+                .and_then(char::from_u32)
+            {
+                Ok(Cow::Owned(ch.to_string()))
+            } else {
+                Err(anyhow!(
+                    "could not interpret '{}' as a Unicode character code",
+                    token.content
+                ))
+            }
+        }
+        TokenKind::Expand => expand_inner(editor, token.content),
+        TokenKind::Expansion(ExpansionKind::Shell) => expand_shell(editor, token.content),
+        // Note: see the docs for this variant.
+        TokenKind::ExpansionKind => unreachable!(
+            "expansion name tokens cannot be emitted when command line validation is enabled"
+        ),
+    }
+}
+
+/// Expand a shell command.
+pub fn expand_shell<'a>(editor: &Editor, content: Cow<'a, str>) -> Result<Cow<'a, str>> {
+    use std::process::{Command, Stdio};
+
+    // Recursively expand the expansion's content before executing the shell command.
+    let content = expand_inner(editor, content)?;
+
+    let config = editor.config();
+    let shell = &config.shell;
+    let mut process = Command::new(&shell[0]);
+    process
+        .args(&shell[1..])
+        .arg(content.as_ref())
+        .stdin(Stdio::null())
+        .stdout(Stdio::piped())
+        .stderr(Stdio::piped());
+
+    // TODO: there is no protection here against a shell command taking a long time.
+    // Ideally you should be able to hit `<ret>` in command mode and then be able to
+    // cancel the invocation (for example with `<C-c>`) if it takes longer than you'd
+    // like.
+    let output = match process.spawn() {
+        Ok(process) => process.wait_with_output()?,
+        Err(err) => {
+            bail!("Failed to start shell: {err}");
+        }
+    };
+
+    let mut text = String::from_utf8_lossy(&output.stdout).into_owned();
+
+    if !output.stderr.is_empty() {
+        log::warn!(
+            "Shell expansion command `{content}` failed: {}",
+            String::from_utf8_lossy(&output.stderr)
+        );
+    }
+
+    // Trim exactly one trailing line ending if it exists.
+    if text.ends_with('\n') {
+        text.pop();
+        if text.ends_with('\r') {
+            text.pop();
+        }
+    }
+
+    Ok(Cow::Owned(text))
+}
+
+/// Expand a token's contents recursively.
+fn expand_inner<'a>(editor: &Editor, content: Cow<'a, str>) -> Result<Cow<'a, str>> {
+    let mut escaped = String::new();
+    let mut start = 0;
+
+    while let Some(offset) = content[start..].find('%') {
+        let idx = start + offset;
+        if content.as_bytes().get(idx + '%'.len_utf8()).copied() == Some(b'%') {
+            // Treat two percents in a row as an escaped percent.
+            escaped.push_str(&content[start..=idx]);
+            // Skip over both percents.
+            start = idx + ('%'.len_utf8() * 2);
+        } else {
+            // Otherwise interpret the percent as an expansion. Push up to (but not
+            // including) the percent token.
+            escaped.push_str(&content[start..idx]);
+            // Then parse the expansion,
+            let mut tokenizer = Tokenizer::new(&content[idx..], true);
+            let token = tokenizer
+                .parse_percent_token()
+                .unwrap()
+                .map_err(|err| anyhow!("{err}"))?;
+            // expand it (this is the recursive part),
+            let expanded = expand(editor, token)?;
+            escaped.push_str(expanded.as_ref());
+            // and move forward to the end of the expansion.
+            start = idx + tokenizer.pos();
+        }
+    }
+
+    if escaped.is_empty() {
+        Ok(content)
+    } else {
+        escaped.push_str(&content[start..]);
+        Ok(Cow::Owned(escaped))
+    }
+}
+
+// Note: the lifetime of the expanded variable (the `Cow`) must not be tied to the lifetime of
+// the borrow of `Editor`. That would prevent commands from mutating the `Editor` until the
+// command consumed or cloned all arguments - this is poor ergonomics. A sensible thing for this
+// function to return then, instead, would normally be a `String`. We can return some statically
+// known strings like the scratch buffer name or line ending strings though, so this function
+// returns a `Cow<'static, str>` instead.
+fn expand_variable(editor: &Editor, variable: Variable) -> Result<Cow<'static, str>> {
+    let (view, doc) = current_ref!(editor);
+    let text = doc.text().slice(..);
+
+    match variable {
+        Variable::CursorLine => {
+            let cursor_line = doc.selection(view.id).primary().cursor_line(text);
+            Ok(Cow::Owned((cursor_line + 1).to_string()))
+        }
+        Variable::CursorColumn => {
+            let cursor = doc.selection(view.id).primary().cursor(text);
+            let position = helix_core::coords_at_pos(text, cursor);
+            Ok(Cow::Owned((position.col + 1).to_string()))
+        }
+        Variable::BufferName => {
+            // Note: usually we would use `Document::display_name` but we can statically borrow
+            // the scratch buffer name by partially reimplementing `display_name`.
+            if let Some(path) = doc.relative_path() {
+                Ok(Cow::Owned(path.to_string_lossy().into_owned()))
+            } else {
+                Ok(Cow::Borrowed(crate::document::SCRATCH_BUFFER_NAME))
+            }
+        }
+        Variable::LineEnding => Ok(Cow::Borrowed(doc.line_ending.as_str())),
+    }
+}
diff --git a/helix-view/src/lib.rs b/helix-view/src/lib.rs
index d54b49ef5400..e30a233816da 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;


Test patch:
diff --git a/helix-term/tests/integration.rs b/helix-term/tests/integration.rs
index 35214bcb8011..5e418cebdabd 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 000000000000..d9a0166992a8
--- /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<ret>:theme d<C-n><tab>"),
+        Some(&|app| {
+            assert!(!app.editor.is_err());
+        }),
+        false,
+    )
+    .await?;
+
+    Ok(())
+}
+
+async fn test_statusline(
+    line: &str,
+    expected_status: &str,
+    expected_severity: Severity,
+) -> anyhow::Result<()> {
+    test_key_sequence(
+        &mut AppBuilder::new().build()?,
+        Some(&format!("{line}<ret>")),
+        Some(&|app| {
+            let (status, &severity) = app.editor.get_status().unwrap();
+            assert_eq!(
+                severity, expected_severity,
+                "'{line}' printed {severity:?}: {status}"
+            );
+            assert_eq!(status.as_ref(), expected_status);
+        }),
+        false,
+    )
+    .await
+}
+
+#[tokio::test(flavor = "multi_thread")]
+async fn variable_expansion() -> anyhow::Result<()> {
+    test_statusline(r#":echo %{cursor_line}"#, "1", Severity::Info).await?;
+    // Double quotes can be used with expansions:
+    test_statusline(
+        r#":echo "line%{cursor_line}line""#,
+        "line1line",
+        Severity::Info,
+    )
+    .await?;
+    // Within double quotes you can escape the percent token for an expansion by doubling it.
+    test_statusline(
+        r#":echo "%%{cursor_line}""#,
+        "%{cursor_line}",
+        Severity::Info,
+    )
+    .await?;
+
+    Ok(())
+}
+
+#[tokio::test(flavor = "multi_thread")]
+async fn unicode_expansion() -> anyhow::Result<()> {
+    test_statusline(r#":echo %u{20}"#, " ", Severity::Info).await?;
+    test_statusline(r#":echo %u{0020}"#, " ", Severity::Info).await?;
+    test_statusline(r#":echo %u{25CF}"#, "●", Severity::Info).await?;
+    // Not a valid Unicode codepoint:
+    test_statusline(
+        r#":echo %u{deadbeef}"#,
+        "'echo': could not interpret 'deadbeef' as a Unicode character code",
+        Severity::Error,
+    )
+    .await?;
+
+    Ok(())
+}
+
+#[cfg(unix)]
+#[tokio::test(flavor = "multi_thread")]
+async fn shell_expansion() -> anyhow::Result<()> {
+    test_statusline(
+        r#":echo %sh{echo "hello world"}"#,
+        "hello world",
+        Severity::Info,
+    )
+    .await?;
+
+    // Shell expansion is recursive.
+    test_statusline(":echo %sh{echo '%{cursor_line}'}", "1", Severity::Info).await?;
+
+    Ok(())
+}
diff --git a/helix-term/tests/test/prompt.rs b/helix-term/tests/test/prompt.rs
deleted file mode 100644
index 4f3bf76329dc..000000000000
--- 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<ret>:theme d<C-n><tab>"),
-        Some(&|app| {
-            assert!(!app.editor.is_err());
-        }),
-        false,
-    )
-    .await?;
-
-    Ok(())
-}