helix/helix-view/src/expansion.rs

229 lines
8.6 KiB
Rust

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,
// The name of current buffers language as set in `languages.toml`
Language,
}
impl Variable {
pub const VARIANTS: &'static [Self] = &[
Self::CursorLine,
Self::CursorColumn,
Self::BufferName,
Self::LineEnding,
Self::Language,
];
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",
Self::Language => "language",
}
}
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),
"language" => Some(Self::Language),
_ => 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())),
Variable::Language => Ok(match doc.language_name() {
Some(lang) => Cow::Owned(lang.to_owned()),
None => Cow::Borrowed("text"),
}),
}
}