Unnamed repository; edit this file 'description' to name the repository.
Diffstat (limited to 'helix-view/src/expansion.rs')
-rw-r--r--helix-view/src/expansion.rs219
1 files changed, 219 insertions, 0 deletions
diff --git a/helix-view/src/expansion.rs b/helix-view/src/expansion.rs
new file mode 100644
index 00000000..96a71b8e
--- /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())),
+ }
+}