From f7cdf69978f52c6777e2475125a97b362d103e55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20R?= Date: Sat, 6 May 2023 08:17:55 -0600 Subject: [PATCH 01/23] Add command expansions %key{body} MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Based on: https://github.com/helix-editor/helix/pull/3393 --- Squashed commit of the following: commit 4e16272eb27fde410022785ffcdb6828ac61aee9 Merge: 07b109d2 5ae30f19 Author: Jesús R Date: Sat May 6 07:56:28 2023 -0600 Merge branch 'master' into cmd-expansions commit 07b109d289e429d4058480f3087301d07f3001a6 Author: Jesús R Date: Mon Apr 24 19:14:58 2023 -0600 fix: Not parsing sh body commit 8bf040f37aeb39077072bb6acf0a61c2085f6086 Author: Jesús R Date: Sat Apr 22 22:46:17 2023 -0600 Use %key{body} commit f6fea44ecfb203efe3d56814304fef190371d090 Merge: ffb40def b7c62e20 Author: Jesús R Date: Sat Apr 22 17:34:07 2023 -0600 Merge branch 'master' into cmd-expansions commit ffb40defc6077509e7e459b86c78636d5a22d365 Merge: 2be5d34c b9b4ed5c Author: Jesús R Date: Tue Apr 11 15:48:15 2023 -0600 Merge branch 'master' into cmd-expansions commit 2be5d34cd937e0569f3415b77e86dc76624b9657 Author: Jesús R Date: Tue Apr 11 15:46:58 2023 -0600 Use #{} for variables and #key [] for commands commit 7e7c0dcaccf05f365df750cedb702703ad7fa90c Merge: 22d17f98 531b745c Author: Bob Date: Wed Apr 5 09:11:13 2023 +0800 Merge branch 'master' into cmd-expansions commit 22d17f9850fa150d0342a463a8c05958d33e92e2 Author: Bob Date: Thu Jan 26 09:43:13 2023 +0800 Update helix-term/src/commands/typed.rs Co-authored-by: Ivan Tham commit 85d38a714fcb6595ec795340fb7f5706bd54d4fa Author: Bob Qi Date: Tue Jan 24 14:49:16 2023 +0800 remove command group function commit 784380f37de9d1d83ce1c3a5c35437cf58f688b0 Merge: 6f6cb3cc 64ec0256 Author: Bob Qi Date: Tue Jan 24 14:42:31 2023 +0800 Merge remote-tracking branch 'origin/master' commit 6f6cb3ccbf7087042c70a61b9d57a087eaa09592 Author: Bob Qi Date: Mon Nov 21 10:39:52 2022 +0800 support commmand expansion --- helix-term/src/commands.rs | 21 ++--- helix-term/src/commands/typed.rs | 149 +++++++++++++++++++++++++------ 2 files changed, 135 insertions(+), 35 deletions(-) diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index 53783e4e3024..029826319db1 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -196,16 +196,17 @@ impl MappableCommand { pub fn execute(&self, cx: &mut Context) { match &self { Self::Typable { name, args, doc: _ } => { - let args: Vec> = args.iter().map(Cow::from).collect(); - if let Some(command) = typed::TYPABLE_COMMAND_MAP.get(name.as_str()) { - let mut cx = compositor::Context { - editor: cx.editor, - jobs: cx.jobs, - scroll: None, - }; - if let Err(e) = (command.fun)(&mut cx, &args[..], PromptEvent::Validate) { - cx.editor.set_error(format!("{}", e)); - } + let mut cx = compositor::Context { + editor: cx.editor, + jobs: cx.jobs, + scroll: None, + }; + if let Err(e) = typed::process_cmd( + &mut cx, + &format!("{} {}", name, args.join(" ")), + PromptEvent::Validate, + ) { + cx.editor.set_error(format!("{}", e)); } } Self::Static { fun, .. } => (fun)(cx), diff --git a/helix-term/src/commands/typed.rs b/helix-term/src/commands/typed.rs index ee02a7d25e4d..1bef527d6c76 100644 --- a/helix-term/src/commands/typed.rs +++ b/helix-term/src/commands/typed.rs @@ -2471,6 +2471,53 @@ fn move_buffer( Ok(()) } +pub fn process_cmd( + cx: &mut compositor::Context, + input: &str, + event: PromptEvent, +) -> anyhow::Result<()> { + let input: String = if event == PromptEvent::Validate { + match expand_args(cx.editor, input) { + Ok(expanded) => expanded, + Err(e) => { + cx.editor.set_error(format!("{e}")); + return Err(e); + } + } + } else { + input.to_owned() + }; + + let parts = input.split_whitespace().collect::>(); + if parts.is_empty() { + return Ok(()); + } + + // If command is numeric, interpret as line number and go there. + if parts.len() == 1 && parts[0].parse::().ok().is_some() { + if let Err(e) = typed::goto_line_number(cx, &[Cow::from(parts[0])], event) { + cx.editor.set_error(format!("{}", e)); + return Err(e); + } + return Ok(()); + } + + // Handle typable commands + if let Some(cmd) = typed::TYPABLE_COMMAND_MAP.get(parts[0]) { + let shellwords = shellwords::Shellwords::from(input.as_ref()); + let args = shellwords.words(); + + if let Err(e) = (cmd.fun)(cx, &args[1..], event) { + cx.editor.set_error(format!("{}", e)); + return Err(e); + } + } else if event == PromptEvent::Validate { + cx.editor + .set_error(format!("no such command: '{}'", parts[0])); + } + Ok(()) +} + pub const TYPABLE_COMMAND_LIST: &[TypableCommand] = &[ TypableCommand { name: "quit", @@ -3141,31 +3188,7 @@ pub(super) fn command_mode(cx: &mut Context) { } }, // completion move |cx: &mut compositor::Context, input: &str, event: PromptEvent| { - let parts = input.split_whitespace().collect::>(); - if parts.is_empty() { - return; - } - - // If command is numeric, interpret as line number and go there. - if parts.len() == 1 && parts[0].parse::().ok().is_some() { - if let Err(e) = typed::goto_line_number(cx, &[Cow::from(parts[0])], event) { - cx.editor.set_error(format!("{}", e)); - } - return; - } - - // Handle typable commands - if let Some(cmd) = typed::TYPABLE_COMMAND_MAP.get(parts[0]) { - let shellwords = Shellwords::from(input); - let args = shellwords.words(); - - 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])); - } + let _ = process_cmd(cx, input, event); }, ); prompt.doc_fn = Box::new(|input: &str| { @@ -3188,6 +3211,82 @@ pub(super) fn command_mode(cx: &mut Context) { cx.push_layer(Box::new(prompt)); } +fn expand_args(editor: &Editor, args: &str) -> anyhow::Result { + let regexp = regex::Regex::new(r"%(\w+)\s*\{([^{}]*(\{[^{}]*\}[^{}]*)*)\}").unwrap(); + + let view = editor.tree.get(editor.tree.focus); + let doc = editor.documents.get(&view.doc).unwrap(); + let shell = &editor.config().shell; + + replace_all(®exp, args, move |captures| { + let keyword = captures.get(1).unwrap().as_str(); + let body = captures.get(2).unwrap().as_str(); + + match keyword.trim() { + "val" => match body.trim() { + "filename" => doc + .path() + .and_then(|p| p.to_str()) + .map_or(Err(anyhow::anyhow!("Current buffer has no path")), |v| { + Ok(v.to_owned()) + }), + "filedir" => doc + .path() + .and_then(|p| p.parent()) + .and_then(|p| p.to_str()) + .map_or( + Err(anyhow::anyhow!("Current buffer has no path or parent")), + |v| Ok(v.to_owned()), + ), + "line_number" => Ok((doc + .selection(view.id) + .primary() + .cursor_line(doc.text().slice(..)) + + 1) + .to_string()), + _ => anyhow::bail!("Unknown variable: {body}"), + }, + "sh" => { + let result = shell_impl(shell, &expand_args(editor, body)?, None)?; + + Ok(result.0.trim().to_string()) + } + _ => anyhow::bail!("Unknown keyword {keyword}"), + } + }) +} + +// Copy of regex::Regex::replace_all to allow using result in the replacer function +fn replace_all( + regex: ®ex::Regex, + text: &str, + matcher: impl Fn(®ex::Captures) -> anyhow::Result, +) -> anyhow::Result { + let mut it = regex.captures_iter(text).peekable(); + + if it.peek().is_none() { + return Ok(String::from(text)); + } + + let mut new = String::with_capacity(text.len()); + let mut last_match = 0; + + for cap in it { + let m = cap.get(0).unwrap(); + new.push_str(&text[last_match..m.start()]); + + let replace = matcher(&cap)?; + + new.push_str(&replace); + + last_match = m.end(); + } + + new.push_str(&text[last_match..]); + + replace_all(regex, &new, matcher) +} + fn argument_number_of(shellwords: &Shellwords) -> usize { if shellwords.ends_with_whitespace() { shellwords.words().len().saturating_sub(1) From aca7d51e7a4c3dcda63f4c343a0ef04919430971 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20R?= Date: Sat, 6 May 2023 13:36:05 -0600 Subject: [PATCH 02/23] Add some requested changes --- helix-term/src/commands.rs | 21 ++++++++---- helix-term/src/commands/typed.rs | 57 ++++++++++++++------------------ 2 files changed, 40 insertions(+), 38 deletions(-) diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index 029826319db1..8eb9045e506c 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -201,12 +201,21 @@ impl MappableCommand { jobs: cx.jobs, scroll: None, }; - if let Err(e) = typed::process_cmd( - &mut cx, - &format!("{} {}", name, args.join(" ")), - PromptEvent::Validate, - ) { - cx.editor.set_error(format!("{}", e)); + if let Some(command) = typed::TYPABLE_COMMAND_MAP.get(name.as_str()) { + let args = args.join(" "); + let args = match typed::expand_args(cx.editor, &args) { + Ok(a) => a, + Err(e) => { + cx.editor.set_error(format!("{}", e)); + return; + } + }; + + let args: Vec> = args.split_whitespace().map(Cow::from).collect(); + + if let Err(e) = (command.fun)(&mut cx, &args[..], PromptEvent::Validate) { + cx.editor.set_error(format!("{}", e)); + } } } Self::Static { fun, .. } => (fun)(cx), diff --git a/helix-term/src/commands/typed.rs b/helix-term/src/commands/typed.rs index 1bef527d6c76..12e5eeafc5c2 100644 --- a/helix-term/src/commands/typed.rs +++ b/helix-term/src/commands/typed.rs @@ -2476,16 +2476,10 @@ pub fn process_cmd( input: &str, event: PromptEvent, ) -> anyhow::Result<()> { - let input: String = if event == PromptEvent::Validate { - match expand_args(cx.editor, input) { - Ok(expanded) => expanded, - Err(e) => { - cx.editor.set_error(format!("{e}")); - return Err(e); - } - } + let input: Cow = if event == PromptEvent::Validate { + expand_args(cx.editor, input)? } else { - input.to_owned() + Cow::Borrowed(input) }; let parts = input.split_whitespace().collect::>(); @@ -3211,25 +3205,24 @@ pub(super) fn command_mode(cx: &mut Context) { cx.push_layer(Box::new(prompt)); } -fn expand_args(editor: &Editor, args: &str) -> anyhow::Result { - let regexp = regex::Regex::new(r"%(\w+)\s*\{([^{}]*(\{[^{}]*\}[^{}]*)*)\}").unwrap(); +static EXPAND_ARGS_REGEXP: Lazy = + Lazy::new(|| Regex::new(r"%(\w+)\{([^{}]*(\{[^{}]*\}[^{}]*)*)\}").unwrap()); +pub fn expand_args<'a>(editor: &Editor, args: &'a str) -> anyhow::Result> { let view = editor.tree.get(editor.tree.focus); let doc = editor.documents.get(&view.doc).unwrap(); let shell = &editor.config().shell; - replace_all(®exp, args, move |captures| { - let keyword = captures.get(1).unwrap().as_str(); - let body = captures.get(2).unwrap().as_str(); - - match keyword.trim() { + replace_all( + Lazy::force(&EXPAND_ARGS_REGEXP), + Cow::Borrowed(args), + move |keyword, body| match keyword.trim() { "val" => match body.trim() { - "filename" => doc - .path() - .and_then(|p| p.to_str()) - .map_or(Err(anyhow::anyhow!("Current buffer has no path")), |v| { - Ok(v.to_owned()) - }), + "filename" => Ok((match doc.path() { + Some(p) => p.to_str().unwrap(), + None => SCRATCH_BUFFER_NAME, + }) + .to_owned()), "filedir" => doc .path() .and_then(|p| p.parent()) @@ -3252,20 +3245,20 @@ fn expand_args(editor: &Editor, args: &str) -> anyhow::Result { Ok(result.0.trim().to_string()) } _ => anyhow::bail!("Unknown keyword {keyword}"), - } - }) + }, + ) } // Copy of regex::Regex::replace_all to allow using result in the replacer function -fn replace_all( +fn replace_all<'a>( regex: ®ex::Regex, - text: &str, - matcher: impl Fn(®ex::Captures) -> anyhow::Result, -) -> anyhow::Result { - let mut it = regex.captures_iter(text).peekable(); + text: Cow<'a, str>, + matcher: impl Fn(&str, &str) -> anyhow::Result, +) -> anyhow::Result> { + let mut it = regex.captures_iter(&text).peekable(); if it.peek().is_none() { - return Ok(String::from(text)); + return Ok(text); } let mut new = String::with_capacity(text.len()); @@ -3275,7 +3268,7 @@ fn replace_all( let m = cap.get(0).unwrap(); new.push_str(&text[last_match..m.start()]); - let replace = matcher(&cap)?; + let replace = matcher(cap.get(1).unwrap().as_str(), cap.get(2).unwrap().as_str())?; new.push_str(&replace); @@ -3284,7 +3277,7 @@ fn replace_all( new.push_str(&text[last_match..]); - replace_all(regex, &new, matcher) + replace_all(regex, Cow::Owned(new), matcher) } fn argument_number_of(shellwords: &Shellwords) -> usize { From e6801c8b086bfe15d438545a2229b69d5d1cb722 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20R?= Date: Mon, 8 May 2023 08:02:40 -0600 Subject: [PATCH 03/23] Change "filedir" to "dirname" --- helix-term/src/commands/typed.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/helix-term/src/commands/typed.rs b/helix-term/src/commands/typed.rs index 12e5eeafc5c2..54aeb664ba4e 100644 --- a/helix-term/src/commands/typed.rs +++ b/helix-term/src/commands/typed.rs @@ -3223,7 +3223,7 @@ pub fn expand_args<'a>(editor: &Editor, args: &'a str) -> anyhow::Result SCRATCH_BUFFER_NAME, }) .to_owned()), - "filedir" => doc + "dirname" => doc .path() .and_then(|p| p.parent()) .and_then(|p| p.to_str()) From 8b1aa90c486b9af65dbf634a4a2fadc9a151b7d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20R?= Date: Wed, 10 May 2023 07:50:02 -0600 Subject: [PATCH 04/23] Use standalone implementation for %sh{} --- helix-term/src/commands/typed.rs | 36 +++++++++++++++++++++++++++----- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/helix-term/src/commands/typed.rs b/helix-term/src/commands/typed.rs index 54aeb664ba4e..26927d1c7c74 100644 --- a/helix-term/src/commands/typed.rs +++ b/helix-term/src/commands/typed.rs @@ -3209,8 +3209,7 @@ static EXPAND_ARGS_REGEXP: Lazy = Lazy::new(|| Regex::new(r"%(\w+)\{([^{}]*(\{[^{}]*\}[^{}]*)*)\}").unwrap()); pub fn expand_args<'a>(editor: &Editor, args: &'a str) -> anyhow::Result> { - let view = editor.tree.get(editor.tree.focus); - let doc = editor.documents.get(&view.doc).unwrap(); + let (view, doc) = current_ref!(editor); let shell = &editor.config().shell; replace_all( @@ -3240,9 +3239,36 @@ pub fn expand_args<'a>(editor: &Editor, args: &'a str) -> anyhow::Result anyhow::bail!("Unknown variable: {body}"), }, "sh" => { - let result = shell_impl(shell, &expand_args(editor, body)?, None)?; - - Ok(result.0.trim().to_string()) + let result = tokio::task::block_in_place(move || { + helix_lsp::block_on(async move { + let args = &expand_args(editor, body)?[..]; + + let mut command = tokio::process::Command::new(&shell[0]); + command.args(&shell[1..]).arg(args); + + let output = command + .output() + .await + .map_err(|_| anyhow::anyhow!("Shell command failed: {args}"))?; + + if output.status.success() { + String::from_utf8(output.stdout) + .map_err(|_| anyhow::anyhow!("Process did not output valid UTF-8")) + } else if output.stderr.is_empty() { + Err(anyhow::anyhow!("Shell command failed: {args}")) + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + + Err(anyhow::anyhow!("{stderr}")) + } + }) + }); + + result.map_err(|it| { + log::error!("{}", it.to_string()); + + it + }) } _ => anyhow::bail!("Unknown keyword {keyword}"), }, From f54ef6b615220554f89fcf80864e3c4a4a0ae617 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20R?= Date: Wed, 10 May 2023 08:11:21 -0600 Subject: [PATCH 05/23] Move expand_args() to helix_view::editor --- helix-term/src/commands.rs | 6 +- helix-term/src/commands/typed.rs | 103 +------------------------------ helix-view/src/editor.rs | 103 +++++++++++++++++++++++++++++++ 3 files changed, 107 insertions(+), 105 deletions(-) diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index 8eb9045e506c..8a343ed2fb29 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -203,10 +203,10 @@ impl MappableCommand { }; if let Some(command) = typed::TYPABLE_COMMAND_MAP.get(name.as_str()) { let args = args.join(" "); - let args = match typed::expand_args(cx.editor, &args) { + let args = match helix_view::editor::expand_args(cx.editor, &args) { Ok(a) => a, Err(e) => { - cx.editor.set_error(format!("{}", e)); + cx.editor.set_error(format!("{e}")); return; } }; @@ -214,7 +214,7 @@ impl MappableCommand { let args: Vec> = args.split_whitespace().map(Cow::from).collect(); if let Err(e) = (command.fun)(&mut cx, &args[..], PromptEvent::Validate) { - cx.editor.set_error(format!("{}", e)); + cx.editor.set_error(format!("{e}")); } } } diff --git a/helix-term/src/commands/typed.rs b/helix-term/src/commands/typed.rs index 26927d1c7c74..2f3f0bf138f3 100644 --- a/helix-term/src/commands/typed.rs +++ b/helix-term/src/commands/typed.rs @@ -2477,7 +2477,7 @@ pub fn process_cmd( event: PromptEvent, ) -> anyhow::Result<()> { let input: Cow = if event == PromptEvent::Validate { - expand_args(cx.editor, input)? + helix_view::editor::expand_args(cx.editor, input)? } else { Cow::Borrowed(input) }; @@ -3205,107 +3205,6 @@ pub(super) fn command_mode(cx: &mut Context) { cx.push_layer(Box::new(prompt)); } -static EXPAND_ARGS_REGEXP: Lazy = - Lazy::new(|| Regex::new(r"%(\w+)\{([^{}]*(\{[^{}]*\}[^{}]*)*)\}").unwrap()); - -pub fn expand_args<'a>(editor: &Editor, args: &'a str) -> anyhow::Result> { - let (view, doc) = current_ref!(editor); - let shell = &editor.config().shell; - - replace_all( - Lazy::force(&EXPAND_ARGS_REGEXP), - Cow::Borrowed(args), - move |keyword, body| match keyword.trim() { - "val" => match body.trim() { - "filename" => Ok((match doc.path() { - Some(p) => p.to_str().unwrap(), - None => SCRATCH_BUFFER_NAME, - }) - .to_owned()), - "dirname" => doc - .path() - .and_then(|p| p.parent()) - .and_then(|p| p.to_str()) - .map_or( - Err(anyhow::anyhow!("Current buffer has no path or parent")), - |v| Ok(v.to_owned()), - ), - "line_number" => Ok((doc - .selection(view.id) - .primary() - .cursor_line(doc.text().slice(..)) - + 1) - .to_string()), - _ => anyhow::bail!("Unknown variable: {body}"), - }, - "sh" => { - let result = tokio::task::block_in_place(move || { - helix_lsp::block_on(async move { - let args = &expand_args(editor, body)?[..]; - - let mut command = tokio::process::Command::new(&shell[0]); - command.args(&shell[1..]).arg(args); - - let output = command - .output() - .await - .map_err(|_| anyhow::anyhow!("Shell command failed: {args}"))?; - - if output.status.success() { - String::from_utf8(output.stdout) - .map_err(|_| anyhow::anyhow!("Process did not output valid UTF-8")) - } else if output.stderr.is_empty() { - Err(anyhow::anyhow!("Shell command failed: {args}")) - } else { - let stderr = String::from_utf8_lossy(&output.stderr); - - Err(anyhow::anyhow!("{stderr}")) - } - }) - }); - - result.map_err(|it| { - log::error!("{}", it.to_string()); - - it - }) - } - _ => anyhow::bail!("Unknown keyword {keyword}"), - }, - ) -} - -// Copy of regex::Regex::replace_all to allow using result in the replacer function -fn replace_all<'a>( - regex: ®ex::Regex, - text: Cow<'a, str>, - matcher: impl Fn(&str, &str) -> anyhow::Result, -) -> anyhow::Result> { - let mut it = regex.captures_iter(&text).peekable(); - - if it.peek().is_none() { - return Ok(text); - } - - let mut new = String::with_capacity(text.len()); - let mut last_match = 0; - - for cap in it { - let m = cap.get(0).unwrap(); - new.push_str(&text[last_match..m.start()]); - - let replace = matcher(cap.get(1).unwrap().as_str(), cap.get(2).unwrap().as_str())?; - - new.push_str(&replace); - - last_match = m.end(); - } - - new.push_str(&text[last_match..]); - - replace_all(regex, Cow::Owned(new), matcher) -} - fn argument_number_of(shellwords: &Shellwords) -> usize { if shellwords.ends_with_whitespace() { shellwords.words().len().saturating_sub(1) diff --git a/helix-view/src/editor.rs b/helix-view/src/editor.rs index 0ab4be8b2fa5..5a614f5f8c6b 100644 --- a/helix-view/src/editor.rs +++ b/helix-view/src/editor.rs @@ -1970,3 +1970,106 @@ fn try_restore_indent(doc: &mut Document, view: &mut View) { doc.apply(&transaction, view.id); } } + +static EXPAND_ARGS_REGEXP: once_cell::sync::Lazy = + once_cell::sync::Lazy::new(|| { + helix_core::regex::Regex::new(r"%(\w+)\{([^{}]*(\{[^{}]*\}[^{}]*)*)\}").unwrap() + }); + +pub fn expand_args<'a>(editor: &Editor, args: &'a str) -> anyhow::Result> { + let (view, doc) = current_ref!(editor); + let shell = &editor.config().shell; + + replace_all( + once_cell::sync::Lazy::force(&EXPAND_ARGS_REGEXP), + Cow::Borrowed(args), + move |keyword, body| match keyword.trim() { + "val" => match body.trim() { + "filename" => Ok(doc + .path() + .and_then(|it| it.to_str()) + .unwrap_or(crate::document::SCRATCH_BUFFER_NAME) + .to_string()), + "dirname" => doc + .path() + .and_then(|p| p.parent()) + .and_then(std::path::Path::to_str) + .map_or( + Err(anyhow::anyhow!("Current buffer has no path or parent")), + |v| Ok(v.to_string()), + ), + "line_number" => Ok((doc + .selection(view.id) + .primary() + .cursor_line(doc.text().slice(..)) + + 1) + .to_string()), + _ => anyhow::bail!("Unknown variable: {body}"), + }, + "sh" => { + let result = tokio::task::block_in_place(move || { + helix_lsp::block_on(async move { + let args = &expand_args(editor, body)?[..]; + + let mut command = tokio::process::Command::new(&shell[0]); + command.args(&shell[1..]).arg(args); + + let output = command + .output() + .await + .map_err(|_| anyhow::anyhow!("Shell command failed: {args}"))?; + + if output.status.success() { + String::from_utf8(output.stdout) + .map_err(|_| anyhow::anyhow!("Process did not output valid UTF-8")) + } else if output.stderr.is_empty() { + Err(anyhow::anyhow!("Shell command failed: {args}")) + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + + Err(anyhow::anyhow!("{stderr}")) + } + }) + }); + + result.map_err(|it| { + log::error!("{}", it.to_string()); + + it + }) + } + _ => anyhow::bail!("Unknown keyword {keyword}"), + }, + ) +} + +// Copy of regex::Regex::replace_all to allow using result in the replacer function +fn replace_all<'a>( + regex: &helix_core::regex::Regex, + text: Cow<'a, str>, + matcher: impl Fn(&str, &str) -> anyhow::Result, +) -> anyhow::Result> { + let mut it = regex.captures_iter(&text).peekable(); + + if it.peek().is_none() { + return Ok(text); + } + + let mut new = String::with_capacity(text.len()); + let mut last_match = 0; + + for cap in it { + let m = cap.get(0).unwrap(); + new.push_str(&text[last_match..m.start()]); + + let replace = matcher(cap.get(1).unwrap().as_str(), cap.get(2).unwrap().as_str())?; + + new.push_str(&replace); + + last_match = m.end(); + } + + new.push_str(&text[last_match..]); + + replace_all(regex, Cow::Owned(new), matcher) +} From 66ddd23158cf2c63e3a7753ce0821c0774b21787 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20R?= Date: Thu, 11 May 2023 22:53:34 -0600 Subject: [PATCH 06/23] Add requested changes --- helix-term/src/commands.rs | 30 +++++++++------- helix-term/src/commands/typed.rs | 33 +++++++++++++++++- helix-view/src/editor.rs | 60 ++++++++++++++------------------ 3 files changed, 75 insertions(+), 48 deletions(-) diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index 8a343ed2fb29..0e5c3c9f7979 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -196,25 +196,29 @@ impl MappableCommand { pub fn execute(&self, cx: &mut Context) { match &self { Self::Typable { name, args, doc: _ } => { - let mut cx = compositor::Context { - editor: cx.editor, - jobs: cx.jobs, - scroll: None, - }; if let Some(command) = typed::TYPABLE_COMMAND_MAP.get(name.as_str()) { - let args = args.join(" "); - let args = match helix_view::editor::expand_args(cx.editor, &args) { - Ok(a) => a, - Err(e) => { - cx.editor.set_error(format!("{e}")); + let mut cx = compositor::Context { + editor: cx.editor, + jobs: cx.jobs, + scroll: None, + }; + + let args: Vec> = match args + .iter() + .map(|arg| { + helix_view::editor::expand_variables(cx.editor, arg).map(Cow::from) + }) + .collect() + { + Ok(expanded_args) => expanded_args, + Err(err) => { + cx.editor.set_error(err.to_string()); return; } }; - let args: Vec> = args.split_whitespace().map(Cow::from).collect(); - if let Err(e) = (command.fun)(&mut cx, &args[..], PromptEvent::Validate) { - cx.editor.set_error(format!("{e}")); + cx.editor.set_error(format!("{}", e)); } } } diff --git a/helix-term/src/commands/typed.rs b/helix-term/src/commands/typed.rs index 2f3f0bf138f3..b88421e26219 100644 --- a/helix-term/src/commands/typed.rs +++ b/helix-term/src/commands/typed.rs @@ -3182,7 +3182,38 @@ pub(super) fn command_mode(cx: &mut Context) { } }, // completion move |cx: &mut compositor::Context, input: &str, event: PromptEvent| { - let _ = process_cmd(cx, input, event); + let input: Cow = if event == PromptEvent::Validate { + helix_view::editor::expand_variables(cx.editor, input) + .map_or_else(|_| Cow::Borrowed(input), |args| args) + } else { + Cow::Borrowed(input) + }; + + let parts = input.split_whitespace().collect::>(); + if parts.is_empty() { + return; + } + + // If command is numeric, interpret as line number and go there. + if parts.len() == 1 && parts[0].parse::().ok().is_some() { + if let Err(e) = typed::goto_line_number(cx, &[Cow::from(parts[0])], event) { + cx.editor.set_error(format!("{}", e)); + } + return; + } + + // Handle typable commands + if let Some(cmd) = typed::TYPABLE_COMMAND_MAP.get(parts[0]) { + let shellwords = Shellwords::from(input.as_ref()); + let args = shellwords.words(); + + 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| { diff --git a/helix-view/src/editor.rs b/helix-view/src/editor.rs index 5a614f5f8c6b..51bb4bade646 100644 --- a/helix-view/src/editor.rs +++ b/helix-view/src/editor.rs @@ -1971,18 +1971,18 @@ fn try_restore_indent(doc: &mut Document, view: &mut View) { } } -static EXPAND_ARGS_REGEXP: once_cell::sync::Lazy = +static EXPAND_VARIABLES_REGEX: once_cell::sync::Lazy = once_cell::sync::Lazy::new(|| { helix_core::regex::Regex::new(r"%(\w+)\{([^{}]*(\{[^{}]*\}[^{}]*)*)\}").unwrap() }); -pub fn expand_args<'a>(editor: &Editor, args: &'a str) -> anyhow::Result> { +pub fn expand_variables<'a>(editor: &Editor, input: &'a str) -> anyhow::Result> { let (view, doc) = current_ref!(editor); let shell = &editor.config().shell; replace_all( - once_cell::sync::Lazy::force(&EXPAND_ARGS_REGEXP), - Cow::Borrowed(args), + &EXPAND_VARIABLES_REGEX, + Cow::Borrowed(input), move |keyword, body| match keyword.trim() { "val" => match body.trim() { "filename" => Ok(doc @@ -2006,38 +2006,30 @@ pub fn expand_args<'a>(editor: &Editor, args: &'a str) -> anyhow::Result anyhow::bail!("Unknown variable: {body}"), }, - "sh" => { - let result = tokio::task::block_in_place(move || { - helix_lsp::block_on(async move { - let args = &expand_args(editor, body)?[..]; - - let mut command = tokio::process::Command::new(&shell[0]); - command.args(&shell[1..]).arg(args); - - let output = command - .output() - .await - .map_err(|_| anyhow::anyhow!("Shell command failed: {args}"))?; - - if output.status.success() { - String::from_utf8(output.stdout) - .map_err(|_| anyhow::anyhow!("Process did not output valid UTF-8")) - } else if output.stderr.is_empty() { - Err(anyhow::anyhow!("Shell command failed: {args}")) - } else { - let stderr = String::from_utf8_lossy(&output.stderr); - - Err(anyhow::anyhow!("{stderr}")) - } - }) - }); - - result.map_err(|it| { - log::error!("{}", it.to_string()); + "sh" => tokio::task::block_in_place(move || { + helix_lsp::block_on(async move { + let args = &expand_variables(editor, body)?[..]; + + let mut command = tokio::process::Command::new(&shell[0]); + command.args(&shell[1..]).arg(args); + + let output = command + .output() + .await + .map_err(|_| anyhow::anyhow!("Shell command failed: {args}"))?; + + if output.status.success() { + String::from_utf8(output.stdout) + .map_err(|_| anyhow::anyhow!("Process did not output valid UTF-8")) + } else if output.stderr.is_empty() { + Err(anyhow::anyhow!("Shell command failed: {args}")) + } else { + let stderr = String::from_utf8_lossy(&output.stderr); - it + Err(anyhow::anyhow!("{stderr}")) + } }) - } + }), _ => anyhow::bail!("Unknown keyword {keyword}"), }, ) From d360ac6b02b3a7cbea5dd56c4c0184f7df19a209 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20R?= Date: Sat, 13 May 2023 10:03:16 -0600 Subject: [PATCH 07/23] Put variable expansion in its own module --- helix-view/src/editor.rs | 98 +-------------------- helix-view/src/editor/variable_expansion.rs | 98 +++++++++++++++++++++ 2 files changed, 101 insertions(+), 95 deletions(-) create mode 100644 helix-view/src/editor/variable_expansion.rs diff --git a/helix-view/src/editor.rs b/helix-view/src/editor.rs index 51bb4bade646..4b1bb472949f 100644 --- a/helix-view/src/editor.rs +++ b/helix-view/src/editor.rs @@ -1,3 +1,6 @@ +mod variable_expansion; +pub use variable_expansion::expand_variables; + use crate::{ align_view, document::{DocumentSavedEventFuture, DocumentSavedEventResult, Mode, SavePoint}, @@ -1970,98 +1973,3 @@ fn try_restore_indent(doc: &mut Document, view: &mut View) { doc.apply(&transaction, view.id); } } - -static EXPAND_VARIABLES_REGEX: once_cell::sync::Lazy = - once_cell::sync::Lazy::new(|| { - helix_core::regex::Regex::new(r"%(\w+)\{([^{}]*(\{[^{}]*\}[^{}]*)*)\}").unwrap() - }); - -pub fn expand_variables<'a>(editor: &Editor, input: &'a str) -> anyhow::Result> { - let (view, doc) = current_ref!(editor); - let shell = &editor.config().shell; - - replace_all( - &EXPAND_VARIABLES_REGEX, - Cow::Borrowed(input), - move |keyword, body| match keyword.trim() { - "val" => match body.trim() { - "filename" => Ok(doc - .path() - .and_then(|it| it.to_str()) - .unwrap_or(crate::document::SCRATCH_BUFFER_NAME) - .to_string()), - "dirname" => doc - .path() - .and_then(|p| p.parent()) - .and_then(std::path::Path::to_str) - .map_or( - Err(anyhow::anyhow!("Current buffer has no path or parent")), - |v| Ok(v.to_string()), - ), - "line_number" => Ok((doc - .selection(view.id) - .primary() - .cursor_line(doc.text().slice(..)) - + 1) - .to_string()), - _ => anyhow::bail!("Unknown variable: {body}"), - }, - "sh" => tokio::task::block_in_place(move || { - helix_lsp::block_on(async move { - let args = &expand_variables(editor, body)?[..]; - - let mut command = tokio::process::Command::new(&shell[0]); - command.args(&shell[1..]).arg(args); - - let output = command - .output() - .await - .map_err(|_| anyhow::anyhow!("Shell command failed: {args}"))?; - - if output.status.success() { - String::from_utf8(output.stdout) - .map_err(|_| anyhow::anyhow!("Process did not output valid UTF-8")) - } else if output.stderr.is_empty() { - Err(anyhow::anyhow!("Shell command failed: {args}")) - } else { - let stderr = String::from_utf8_lossy(&output.stderr); - - Err(anyhow::anyhow!("{stderr}")) - } - }) - }), - _ => anyhow::bail!("Unknown keyword {keyword}"), - }, - ) -} - -// Copy of regex::Regex::replace_all to allow using result in the replacer function -fn replace_all<'a>( - regex: &helix_core::regex::Regex, - text: Cow<'a, str>, - matcher: impl Fn(&str, &str) -> anyhow::Result, -) -> anyhow::Result> { - let mut it = regex.captures_iter(&text).peekable(); - - if it.peek().is_none() { - return Ok(text); - } - - let mut new = String::with_capacity(text.len()); - let mut last_match = 0; - - for cap in it { - let m = cap.get(0).unwrap(); - new.push_str(&text[last_match..m.start()]); - - let replace = matcher(cap.get(1).unwrap().as_str(), cap.get(2).unwrap().as_str())?; - - new.push_str(&replace); - - last_match = m.end(); - } - - new.push_str(&text[last_match..]); - - replace_all(regex, Cow::Owned(new), matcher) -} diff --git a/helix-view/src/editor/variable_expansion.rs b/helix-view/src/editor/variable_expansion.rs new file mode 100644 index 000000000000..d659205f1d69 --- /dev/null +++ b/helix-view/src/editor/variable_expansion.rs @@ -0,0 +1,98 @@ +use std::borrow::Cow; + +use crate::Editor; + +static EXPAND_VARIABLES_REGEX: once_cell::sync::Lazy = + once_cell::sync::Lazy::new(|| { + helix_core::regex::Regex::new(r"%(\w+)\{([^{}]*(\{[^{}]*\}[^{}]*)*)\}").unwrap() + }); + +pub fn expand_variables<'a>(editor: &Editor, input: &'a str) -> anyhow::Result> { + let (view, doc) = current_ref!(editor); + let shell = &editor.config().shell; + + replace_all( + &EXPAND_VARIABLES_REGEX, + Cow::Borrowed(input), + move |keyword, body| match keyword.trim() { + "val" => match body.trim() { + "filename" => Ok(doc + .path() + .and_then(|it| it.to_str()) + .unwrap_or(crate::document::SCRATCH_BUFFER_NAME) + .to_string()), + "dirname" => doc + .path() + .and_then(|p| p.parent()) + .and_then(std::path::Path::to_str) + .map_or( + Err(anyhow::anyhow!("Current buffer has no path or parent")), + |v| Ok(v.to_string()), + ), + "line_number" => Ok((doc + .selection(view.id) + .primary() + .cursor_line(doc.text().slice(..)) + + 1) + .to_string()), + _ => anyhow::bail!("Unknown variable: {body}"), + }, + "sh" => tokio::task::block_in_place(move || { + helix_lsp::block_on(async move { + let args = &expand_variables(editor, body)?[..]; + + let mut command = tokio::process::Command::new(&shell[0]); + command.args(&shell[1..]).arg(args); + + let output = command + .output() + .await + .map_err(|_| anyhow::anyhow!("Shell command failed: {args}"))?; + + if output.status.success() { + String::from_utf8(output.stdout) + .map_err(|_| anyhow::anyhow!("Process did not output valid UTF-8")) + } else if output.stderr.is_empty() { + Err(anyhow::anyhow!("Shell command failed: {args}")) + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + + Err(anyhow::anyhow!("{stderr}")) + } + }) + }), + _ => anyhow::bail!("Unknown keyword {keyword}"), + }, + ) +} + +// Copy of regex::Regex::replace_all to allow using result in the replacer function +fn replace_all<'a>( + regex: &helix_core::regex::Regex, + text: Cow<'a, str>, + matcher: impl Fn(&str, &str) -> anyhow::Result, +) -> anyhow::Result> { + let mut it = regex.captures_iter(&text).peekable(); + + if it.peek().is_none() { + return Ok(text); + } + + let mut new = String::with_capacity(text.len()); + let mut last_match = 0; + + for cap in it { + let m = cap.get(0).unwrap(); + new.push_str(&text[last_match..m.start()]); + + let replace = matcher(cap.get(1).unwrap().as_str(), cap.get(2).unwrap().as_str())?; + + new.push_str(&replace); + + last_match = m.end(); + } + + new.push_str(&text[last_match..]); + + replace_all(regex, Cow::Owned(new), matcher) +} From cf64b251b77433e06f299aab934103849f5a1ea4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20R?= Date: Sat, 13 May 2023 10:08:02 -0600 Subject: [PATCH 08/23] Fail typed command if variable expansion fails --- helix-term/src/commands/typed.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/helix-term/src/commands/typed.rs b/helix-term/src/commands/typed.rs index b88421e26219..9d9ac8c45569 100644 --- a/helix-term/src/commands/typed.rs +++ b/helix-term/src/commands/typed.rs @@ -3183,8 +3183,13 @@ pub(super) fn command_mode(cx: &mut Context) { }, // completion move |cx: &mut compositor::Context, input: &str, event: PromptEvent| { let input: Cow = if event == PromptEvent::Validate { - helix_view::editor::expand_variables(cx.editor, input) - .map_or_else(|_| Cow::Borrowed(input), |args| args) + match helix_view::editor::expand_variables(cx.editor, input) { + Ok(args) => args, + Err(e) => { + cx.editor.set_error(format!("{}", e)); + return; + } + } } else { Cow::Borrowed(input) }; From c4746115f2620297271d6b8f0c19b4aa7b362d60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20R?= Date: Sat, 13 May 2023 15:11:12 -0600 Subject: [PATCH 09/23] Add unit tests --- helix-view/src/editor/variable_expansion.rs | 85 +++++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/helix-view/src/editor/variable_expansion.rs b/helix-view/src/editor/variable_expansion.rs index d659205f1d69..1c3b1dfcf8f6 100644 --- a/helix-view/src/editor/variable_expansion.rs +++ b/helix-view/src/editor/variable_expansion.rs @@ -96,3 +96,88 @@ fn replace_all<'a>( replace_all(regex, Cow::Owned(new), matcher) } + +#[cfg(test)] +mod tests { + use super::*; + + fn matcher(keyword: &str, body: &str) -> anyhow::Result { + match keyword { + "val" => match body { + "filename" => Ok(String::from("[scratch]")), + "dirname" => Ok(String::from("[parent_dir]")), + _ => Err(anyhow::anyhow!("Unknown variable")), + }, + "sh" => Ok(format!( + "|{}|", + replace_all(&EXPAND_VARIABLES_REGEX, Cow::Borrowed(body), matcher)? + )), + _ => Err(anyhow::anyhow!("Unknown keyword")), + } + } + + // Doesn't allocate for non-matching input + #[test] + fn variable_expansion_does_not_allocate() { + let input = "cd dir"; + let cow: Cow = Cow::Borrowed(input); + + assert!(matches!( + replace_all(&EXPAND_VARIABLES_REGEX, cow, matcher).unwrap(), + Cow::Borrowed(_) + )); + } + + // Does allocate for matching input + #[test] + fn variable_expansion_does_allocate() { + let input = "cd %val{dirname}"; + let cow: Cow = Cow::Borrowed(input); + + assert!(matches!( + replace_all(&EXPAND_VARIABLES_REGEX, cow, matcher).unwrap(), + Cow::Owned(_) + )); + } + + #[test] + fn variable_expansion_fails() { + assert!(replace_all( + &EXPAND_VARIABLES_REGEX, + Cow::Owned(String::from("%key{dirname}")), + matcher + ) + .is_err()); + } + + #[test] + fn variable_expansion_succeeds() { + let list: Vec<(Cow, String)> = vec![ + ( + Cow::Owned(String::from("%val{filename}")), + String::from("[scratch]"), + ), + ( + Cow::Owned(String::from("%sh{body}")), + String::from("|body|"), + ), + ( + Cow::Owned(String::from("%sh{cp %val{filename} %val{dirname}/../copy}")), + String::from("|cp [scratch] [parent_dir]/../copy|"), + ), + ( + Cow::Owned(String::from( + "%sh{%sh{cat %val{filename}} | grep test >> %val{dirname}/copy}", + )), + String::from("||cat [scratch]| | grep test >> [parent_dir]/copy|"), + ), + ]; + + for item in list { + assert_eq!( + replace_all(&EXPAND_VARIABLES_REGEX, item.0, matcher).unwrap(), + item.1 + ); + } + } +} From 4f7413d1f6bc4982127622802781ae886e43626d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20R?= Date: Mon, 15 May 2023 07:30:13 -0600 Subject: [PATCH 10/23] Format with cargo fmt --- helix-view/src/editor.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/helix-view/src/editor.rs b/helix-view/src/editor.rs index 4b1bb472949f..3fdade5f1118 100644 --- a/helix-view/src/editor.rs +++ b/helix-view/src/editor.rs @@ -734,7 +734,7 @@ pub struct WhitespaceCharacters { impl Default for WhitespaceCharacters { fn default() -> Self { Self { - space: '·', // U+00B7 + space: '·', // U+00B7 nbsp: '⍽', // U+237D tab: '→', // U+2192 newline: '⏎', // U+23CE From 3bf311a5ad18e7e95b5f0374c41476547fb3f6d6 Mon Sep 17 00:00:00 2001 From: Jesus R Date: Thu, 24 Aug 2023 21:57:05 -0600 Subject: [PATCH 11/23] Change expansion method --- helix-view/src/editor/variable_expansion.rs | 279 ++++++++------------ 1 file changed, 112 insertions(+), 167 deletions(-) diff --git a/helix-view/src/editor/variable_expansion.rs b/helix-view/src/editor/variable_expansion.rs index 1c3b1dfcf8f6..afaeca6260b4 100644 --- a/helix-view/src/editor/variable_expansion.rs +++ b/helix-view/src/editor/variable_expansion.rs @@ -2,182 +2,127 @@ use std::borrow::Cow; use crate::Editor; -static EXPAND_VARIABLES_REGEX: once_cell::sync::Lazy = - once_cell::sync::Lazy::new(|| { - helix_core::regex::Regex::new(r"%(\w+)\{([^{}]*(\{[^{}]*\}[^{}]*)*)\}").unwrap() - }); - pub fn expand_variables<'a>(editor: &Editor, input: &'a str) -> anyhow::Result> { let (view, doc) = current_ref!(editor); let shell = &editor.config().shell; - replace_all( - &EXPAND_VARIABLES_REGEX, - Cow::Borrowed(input), - move |keyword, body| match keyword.trim() { - "val" => match body.trim() { - "filename" => Ok(doc - .path() - .and_then(|it| it.to_str()) - .unwrap_or(crate::document::SCRATCH_BUFFER_NAME) - .to_string()), - "dirname" => doc - .path() - .and_then(|p| p.parent()) - .and_then(std::path::Path::to_str) - .map_or( - Err(anyhow::anyhow!("Current buffer has no path or parent")), - |v| Ok(v.to_string()), - ), - "line_number" => Ok((doc - .selection(view.id) - .primary() - .cursor_line(doc.text().slice(..)) - + 1) - .to_string()), - _ => anyhow::bail!("Unknown variable: {body}"), - }, - "sh" => tokio::task::block_in_place(move || { - helix_lsp::block_on(async move { - let args = &expand_variables(editor, body)?[..]; - - let mut command = tokio::process::Command::new(&shell[0]); - command.args(&shell[1..]).arg(args); - - let output = command - .output() - .await - .map_err(|_| anyhow::anyhow!("Shell command failed: {args}"))?; - - if output.status.success() { - String::from_utf8(output.stdout) - .map_err(|_| anyhow::anyhow!("Process did not output valid UTF-8")) - } else if output.stderr.is_empty() { - Err(anyhow::anyhow!("Shell command failed: {args}")) - } else { - let stderr = String::from_utf8_lossy(&output.stderr); - - Err(anyhow::anyhow!("{stderr}")) + let mut output: Option = None; + + let mut chars = input.char_indices(); + let mut last_push_end: usize = 0; + + while let Some((index, char)) = chars.next() { + if char == '%' { + if let Some((_, char)) = chars.next() { + if char == '{' { + while let Some((end, char)) = chars.next() { + if char == '}' { + if output == None { + output = Some(String::with_capacity(input.len())) + } + + if let Some(o) = output.as_mut() { + o.push_str(&input[last_push_end..index]); + last_push_end = end + 1; + + let value = match &input[index + 2..end] { + "filename" => doc + .path() + .and_then(|it| it.to_str()) + .unwrap_or(crate::document::SCRATCH_BUFFER_NAME) + .to_owned(), + "dirname" => doc + .path() + .and_then(|p| p.parent()) + .and_then(std::path::Path::to_str) + .unwrap() + .to_owned(), + "linenumber" => (doc + .selection(view.id) + .primary() + .cursor_line(doc.text().slice(..)) + + 1) + .to_string(), + _ => anyhow::bail!("Unknown variable"), + }; + + o.push_str(&value); + + break; + } + } } - }) - }), - _ => anyhow::bail!("Unknown keyword {keyword}"), - }, - ) -} - -// Copy of regex::Regex::replace_all to allow using result in the replacer function -fn replace_all<'a>( - regex: &helix_core::regex::Regex, - text: Cow<'a, str>, - matcher: impl Fn(&str, &str) -> anyhow::Result, -) -> anyhow::Result> { - let mut it = regex.captures_iter(&text).peekable(); - - if it.peek().is_none() { - return Ok(text); - } - - let mut new = String::with_capacity(text.len()); - let mut last_match = 0; - - for cap in it { - let m = cap.get(0).unwrap(); - new.push_str(&text[last_match..m.start()]); - - let replace = matcher(cap.get(1).unwrap().as_str(), cap.get(2).unwrap().as_str())?; - - new.push_str(&replace); - - last_match = m.end(); - } - - new.push_str(&text[last_match..]); - - replace_all(regex, Cow::Owned(new), matcher) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn matcher(keyword: &str, body: &str) -> anyhow::Result { - match keyword { - "val" => match body { - "filename" => Ok(String::from("[scratch]")), - "dirname" => Ok(String::from("[parent_dir]")), - _ => Err(anyhow::anyhow!("Unknown variable")), - }, - "sh" => Ok(format!( - "|{}|", - replace_all(&EXPAND_VARIABLES_REGEX, Cow::Borrowed(body), matcher)? - )), - _ => Err(anyhow::anyhow!("Unknown keyword")), + } else if char == 's' { + if let (Some((_, 'h')), Some((_, '{'))) = (chars.next(), chars.next()) { + let mut right_bracket_remaining = 1; + while let Some((end, char)) = chars.next() { + if char == '}' { + right_bracket_remaining -= 1; + + if right_bracket_remaining == 0 { + if output == None { + output = Some(String::with_capacity(input.len())) + } + + if let Some(o) = output.as_mut() { + let body = + expand_variables(editor, &input[index + 4..end])?; + + let output = tokio::task::block_in_place(move || { + helix_lsp::block_on(async move { + let mut command = + tokio::process::Command::new(&shell[0]); + command.args(&shell[1..]).arg(&body[..]); + + let output = + command.output().await.map_err(|_| { + anyhow::anyhow!( + "Shell command failed: {body}" + ) + })?; + + if output.status.success() { + String::from_utf8(output.stdout).map_err(|_| { + anyhow::anyhow!( + "Process did not output valid UTF-8" + ) + }) + } else if output.stderr.is_empty() { + Err(anyhow::anyhow!( + "Shell command failed: {body}" + )) + } else { + let stderr = + String::from_utf8_lossy(&output.stderr); + + Err(anyhow::anyhow!("{stderr}")) + } + }) + }); + o.push_str(&input[last_push_end..index]); + last_push_end = end + 1; + + o.push_str(&output?); + + break; + } + } + } else if char == '{' { + right_bracket_remaining += 1; + } + } + } + } + } } } - // Doesn't allocate for non-matching input - #[test] - fn variable_expansion_does_not_allocate() { - let input = "cd dir"; - let cow: Cow = Cow::Borrowed(input); - - assert!(matches!( - replace_all(&EXPAND_VARIABLES_REGEX, cow, matcher).unwrap(), - Cow::Borrowed(_) - )); + if let Some(o) = output.as_mut() { + o.push_str(&input[last_push_end..]); } - // Does allocate for matching input - #[test] - fn variable_expansion_does_allocate() { - let input = "cd %val{dirname}"; - let cow: Cow = Cow::Borrowed(input); - - assert!(matches!( - replace_all(&EXPAND_VARIABLES_REGEX, cow, matcher).unwrap(), - Cow::Owned(_) - )); - } - - #[test] - fn variable_expansion_fails() { - assert!(replace_all( - &EXPAND_VARIABLES_REGEX, - Cow::Owned(String::from("%key{dirname}")), - matcher - ) - .is_err()); - } - - #[test] - fn variable_expansion_succeeds() { - let list: Vec<(Cow, String)> = vec![ - ( - Cow::Owned(String::from("%val{filename}")), - String::from("[scratch]"), - ), - ( - Cow::Owned(String::from("%sh{body}")), - String::from("|body|"), - ), - ( - Cow::Owned(String::from("%sh{cp %val{filename} %val{dirname}/../copy}")), - String::from("|cp [scratch] [parent_dir]/../copy|"), - ), - ( - Cow::Owned(String::from( - "%sh{%sh{cat %val{filename}} | grep test >> %val{dirname}/copy}", - )), - String::from("||cat [scratch]| | grep test >> [parent_dir]/copy|"), - ), - ]; - - for item in list { - assert_eq!( - replace_all(&EXPAND_VARIABLES_REGEX, item.0, matcher).unwrap(), - item.1 - ); - } + match output { + Some(o) => Ok(std::borrow::Cow::Owned(o)), + None => Ok(std::borrow::Cow::Borrowed(input)), } } From 1543a2697baf045db95c680eb5c4131bb5c87c23 Mon Sep 17 00:00:00 2001 From: Jesus R Date: Fri, 25 Aug 2023 17:37:53 -0600 Subject: [PATCH 12/23] Trim shell output in variable expansion --- helix-view/src/editor/variable_expansion.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/helix-view/src/editor/variable_expansion.rs b/helix-view/src/editor/variable_expansion.rs index afaeca6260b4..9ba7768a7154 100644 --- a/helix-view/src/editor/variable_expansion.rs +++ b/helix-view/src/editor/variable_expansion.rs @@ -102,7 +102,7 @@ pub fn expand_variables<'a>(editor: &Editor, input: &'a str) -> anyhow::Result Date: Fri, 25 Aug 2023 21:42:21 -0600 Subject: [PATCH 13/23] Merge mapable command args before expanding --- helix-term/src/commands.rs | 22 +++++++++------------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index 0e5c3c9f7979..a6ffc83267ac 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -202,24 +202,20 @@ impl MappableCommand { jobs: cx.jobs, scroll: None, }; + let args = args.join(" "); - let args: Vec> = match args - .iter() - .map(|arg| { - helix_view::editor::expand_variables(cx.editor, arg).map(Cow::from) - }) - .collect() - { - Ok(expanded_args) => expanded_args, + match helix_view::editor::expand_variables(cx.editor, &args) { + Ok(args) => { + let args = [args]; + + if let Err(e) = (command.fun)(&mut cx, &args, PromptEvent::Validate) { + cx.editor.set_error(format!("{}", e)); + } + } Err(err) => { cx.editor.set_error(err.to_string()); - return; } }; - - if let Err(e) = (command.fun)(&mut cx, &args[..], PromptEvent::Validate) { - cx.editor.set_error(format!("{}", e)); - } } } Self::Static { fun, .. } => (fun)(cx), From c0105aa008c293b35014aacf52cf95f1a64fcfbe Mon Sep 17 00:00:00 2001 From: Jesus R Date: Sat, 26 Aug 2023 07:17:00 -0600 Subject: [PATCH 14/23] Split args before executing the command --- helix-term/src/commands.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index a6ffc83267ac..95dfc5cd0c9c 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -202,13 +202,16 @@ impl MappableCommand { jobs: cx.jobs, scroll: None, }; + let args = args.join(" "); match helix_view::editor::expand_variables(cx.editor, &args) { Ok(args) => { - let args = [args]; + let args = args.split_whitespace(); + let args: Vec> = args.map(|it| Cow::Borrowed(it)).collect(); - if let Err(e) = (command.fun)(&mut cx, &args, PromptEvent::Validate) { + if let Err(e) = (command.fun)(&mut cx, &args[..], PromptEvent::Validate) + { cx.editor.set_error(format!("{}", e)); } } From c710ff48518f7c2c2e2c4b10db07a8092bc48679 Mon Sep 17 00:00:00 2001 From: Jesus R Date: Sat, 26 Aug 2023 18:15:00 -0600 Subject: [PATCH 15/23] Add basename and selection variables --- helix-view/src/editor/variable_expansion.rs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/helix-view/src/editor/variable_expansion.rs b/helix-view/src/editor/variable_expansion.rs index 9ba7768a7154..8d876ed6daae 100644 --- a/helix-view/src/editor/variable_expansion.rs +++ b/helix-view/src/editor/variable_expansion.rs @@ -26,6 +26,11 @@ pub fn expand_variables<'a>(editor: &Editor, input: &'a str) -> anyhow::Result doc + .path() + .and_then(|it| it.file_name().and_then(|it| it.to_str())) + .unwrap_or(crate::document::SCRATCH_BUFFER_NAME) + .to_owned(), "filename" => doc .path() .and_then(|it| it.to_str()) @@ -43,10 +48,15 @@ pub fn expand_variables<'a>(editor: &Editor, input: &'a str) -> anyhow::Result doc + .selection(view.id) + .primary() + .fragment(doc.text().slice(..)) + .to_string(), _ => anyhow::bail!("Unknown variable"), }; - o.push_str(&value); + o.push_str(&value.trim()); break; } From 406fb085c88e20a014840845e23e384b29d3481d Mon Sep 17 00:00:00 2001 From: Jesus R Date: Mon, 28 Aug 2023 17:11:01 -0600 Subject: [PATCH 16/23] Add integration tests --- helix-term/tests/test/commands.rs | 1 + .../tests/test/commands/variable_expansion.rs | 56 +++++++++++++++++++ 2 files changed, 57 insertions(+) create mode 100644 helix-term/tests/test/commands/variable_expansion.rs diff --git a/helix-term/tests/test/commands.rs b/helix-term/tests/test/commands.rs index e52b142c6b68..10e1788f12cd 100644 --- a/helix-term/tests/test/commands.rs +++ b/helix-term/tests/test/commands.rs @@ -3,6 +3,7 @@ use helix_term::application::Application; use super::*; mod movement; +mod variable_expansion; mod write; #[tokio::test(flavor = "multi_thread")] diff --git a/helix-term/tests/test/commands/variable_expansion.rs b/helix-term/tests/test/commands/variable_expansion.rs new file mode 100644 index 000000000000..8719ef707495 --- /dev/null +++ b/helix-term/tests/test/commands/variable_expansion.rs @@ -0,0 +1,56 @@ +use super::*; +use helix_view::editor::expand_variables; + +#[tokio::test(flavor = "multi_thread")] +async fn test_variable_expansion() -> anyhow::Result<()> { + let file = tempfile::NamedTempFile::new()?; + let mut app = AppBuilder::new().with_file(file.path(), None).build()?; + + assert_eq!( + expand_variables(&app.editor, "%{filename}").unwrap(), + file.path().to_str().unwrap() + ); + + assert_eq!( + expand_variables(&app.editor, "%{basename}").unwrap(), + file.path().file_name().unwrap().to_str().unwrap() + ); + + assert_eq!( + expand_variables(&app.editor, "%{dirname}").unwrap(), + file.path().parent().unwrap().to_str().unwrap() + ); + + { + let file = tempfile::NamedTempFile::new()?; + let mut app = AppBuilder::new().with_file(file.path(), None).build()?; + test_key_sequence( + &mut app, + Some("ihelix%"), + Some(&|app| { + assert_eq!( + expand_variables(&app.editor, "%{selection}").unwrap(), + "helix" + ); + }), + false, + ) + .await?; + } + + { + let file = tempfile::NamedTempFile::new()?; + let mut app = AppBuilder::new().with_file(file.path(), None).build()?; + test_key_sequence( + &mut app, + Some("ihelix%"), + Some(&|app| { + assert_eq!(expand_variables(&app.editor, "%{linenumber}").unwrap(), "1"); + }), + false, + ) + .await?; + } + + Ok(()) +} From ed4cf00b3ec0736072a638024fc5311664b923ac Mon Sep 17 00:00:00 2001 From: Jesus R Date: Mon, 28 Aug 2023 19:42:18 -0600 Subject: [PATCH 17/23] Fix clippy lints --- helix-term/src/commands.rs | 2 +- helix-view/src/editor/variable_expansion.rs | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index 95dfc5cd0c9c..2250fa145498 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -208,7 +208,7 @@ impl MappableCommand { match helix_view::editor::expand_variables(cx.editor, &args) { Ok(args) => { let args = args.split_whitespace(); - let args: Vec> = args.map(|it| Cow::Borrowed(it)).collect(); + let args: Vec> = args.map(Cow::Borrowed).collect(); if let Err(e) = (command.fun)(&mut cx, &args[..], PromptEvent::Validate) { diff --git a/helix-view/src/editor/variable_expansion.rs b/helix-view/src/editor/variable_expansion.rs index 8d876ed6daae..ec52647e7411 100644 --- a/helix-view/src/editor/variable_expansion.rs +++ b/helix-view/src/editor/variable_expansion.rs @@ -15,9 +15,9 @@ pub fn expand_variables<'a>(editor: &Editor, input: &'a str) -> anyhow::Result(editor: &Editor, input: &'a str) -> anyhow::Result anyhow::bail!("Unknown variable"), }; - o.push_str(&value.trim()); + o.push_str(value.trim()); break; } @@ -65,12 +65,12 @@ pub fn expand_variables<'a>(editor: &Editor, input: &'a str) -> anyhow::Result(editor: &Editor, input: &'a str) -> anyhow::Result Date: Wed, 6 Sep 2023 18:08:13 -0600 Subject: [PATCH 18/23] Use cwd when no buffer is open --- helix-view/src/editor/variable_expansion.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/helix-view/src/editor/variable_expansion.rs b/helix-view/src/editor/variable_expansion.rs index ec52647e7411..504360fd87b3 100644 --- a/helix-view/src/editor/variable_expansion.rs +++ b/helix-view/src/editor/variable_expansion.rs @@ -40,7 +40,7 @@ pub fn expand_variables<'a>(editor: &Editor, input: &'a str) -> anyhow::Result (doc .selection(view.id) From 28372db680411ca4dd803e72306cf1632a00c5ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20R?= Date: Fri, 1 Dec 2023 11:12:06 -0600 Subject: [PATCH 19/23] Test for empy editor and follow /var symlink --- helix-term/src/commands/typed.rs | 2 +- .../tests/test/commands/variable_expansion.rs | 61 +++++++++++++------ 2 files changed, 45 insertions(+), 18 deletions(-) diff --git a/helix-term/src/commands/typed.rs b/helix-term/src/commands/typed.rs index 9d9ac8c45569..86e993aa72c7 100644 --- a/helix-term/src/commands/typed.rs +++ b/helix-term/src/commands/typed.rs @@ -2477,7 +2477,7 @@ pub fn process_cmd( event: PromptEvent, ) -> anyhow::Result<()> { let input: Cow = if event == PromptEvent::Validate { - helix_view::editor::expand_args(cx.editor, input)? + helix_view::editor::expand_variables(cx.editor, input)? } else { Cow::Borrowed(input) }; diff --git a/helix-term/tests/test/commands/variable_expansion.rs b/helix-term/tests/test/commands/variable_expansion.rs index 8719ef707495..8d7a7534b2b1 100644 --- a/helix-term/tests/test/commands/variable_expansion.rs +++ b/helix-term/tests/test/commands/variable_expansion.rs @@ -3,23 +3,50 @@ use helix_view::editor::expand_variables; #[tokio::test(flavor = "multi_thread")] async fn test_variable_expansion() -> anyhow::Result<()> { - let file = tempfile::NamedTempFile::new()?; - let mut app = AppBuilder::new().with_file(file.path(), None).build()?; - - assert_eq!( - expand_variables(&app.editor, "%{filename}").unwrap(), - file.path().to_str().unwrap() - ); - - assert_eq!( - expand_variables(&app.editor, "%{basename}").unwrap(), - file.path().file_name().unwrap().to_str().unwrap() - ); - - assert_eq!( - expand_variables(&app.editor, "%{dirname}").unwrap(), - file.path().parent().unwrap().to_str().unwrap() - ); + { + let mut app = AppBuilder::new().build()?; + + assert_eq!( + expand_variables(&app.editor, "%{filename}").unwrap(), + helix_view::document::SCRATCH_BUFFER_NAME, + ); + + assert_eq!( + expand_variables(&app.editor, "%{basename}").unwrap(), + helix_view::document::SCRATCH_BUFFER_NAME, + ); + + assert_eq!( + expand_variables(&app.editor, "%{dirname}").unwrap(), + std::env::current_dir()?.to_str().unwrap() + ); + } + + { + let file = tempfile::NamedTempFile::new()?; + let mut app = AppBuilder::new().with_file(file.path(), None).build()?; + + assert_eq!( + expand_variables(&app.editor, "%{filename}").unwrap(), + std::fs::canonicalize(file.path().to_str().unwrap()) + .unwrap() + .to_str() + .unwrap() + ); + + assert_eq!( + expand_variables(&app.editor, "%{basename}").unwrap(), + file.path().file_name().unwrap().to_str().unwrap() + ); + + assert_eq!( + expand_variables(&app.editor, "%{dirname}").unwrap(), + std::fs::canonicalize(file.path().parent().unwrap().to_str().unwrap()) + .unwrap() + .to_str() + .unwrap() + ); + } { let file = tempfile::NamedTempFile::new()?; From f38a26bd8eb72741aba4318ad23dc7f25e70a926 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20R?= Date: Fri, 1 Dec 2023 11:56:53 -0600 Subject: [PATCH 20/23] Add echo command. Add %{cwd} --- helix-term/src/commands/typed.rs | 19 +++++++++++++++++++ helix-term/src/ui/mod.rs | 7 +++++++ .../tests/test/commands/variable_expansion.rs | 6 +++--- helix-view/src/editor.rs | 4 ++-- helix-view/src/editor/variable_expansion.rs | 13 ++++++++++++- 5 files changed, 43 insertions(+), 6 deletions(-) diff --git a/helix-term/src/commands/typed.rs b/helix-term/src/commands/typed.rs index 86e993aa72c7..cc7400d89704 100644 --- a/helix-term/src/commands/typed.rs +++ b/helix-term/src/commands/typed.rs @@ -2471,6 +2471,18 @@ fn move_buffer( Ok(()) } +fn echo(cx: &mut compositor::Context, args: &[Cow], event: PromptEvent) -> anyhow::Result<()> { + if event != PromptEvent::Validate { + return Ok(()); + } + + let args = args.join(" "); + + cx.editor.set_status(args); + + Ok(()) +} + pub fn process_cmd( cx: &mut compositor::Context, input: &str, @@ -3119,6 +3131,13 @@ pub const TYPABLE_COMMAND_LIST: &[TypableCommand] = &[ fun: move_buffer, signature: CommandSignature::positional(&[completers::filename]), }, + TypableCommand { + name: "echo", + aliases: &[], + doc: "Print the processed input to the editor status", + fun: echo, + signature: CommandSignature::all(completers::variables) + }, ]; pub static TYPABLE_COMMAND_MAP: Lazy> = diff --git a/helix-term/src/ui/mod.rs b/helix-term/src/ui/mod.rs index efa2473e01ed..7e7cb602bead 100644 --- a/helix-term/src/ui/mod.rs +++ b/helix-term/src/ui/mod.rs @@ -382,6 +382,13 @@ pub mod completers { }) } + pub fn variables(_: &Editor, input: &str) -> Vec { + fuzzy_match(input, helix_view::editor::VARIABLES, false) + .into_iter() + .map(|(name, _)| ((0..), name.to_owned().into())) + .collect() + } + #[derive(Copy, Clone, PartialEq, Eq)] enum FileMatch { /// Entry should be ignored diff --git a/helix-term/tests/test/commands/variable_expansion.rs b/helix-term/tests/test/commands/variable_expansion.rs index 8d7a7534b2b1..6c8f8952c919 100644 --- a/helix-term/tests/test/commands/variable_expansion.rs +++ b/helix-term/tests/test/commands/variable_expansion.rs @@ -18,7 +18,7 @@ async fn test_variable_expansion() -> anyhow::Result<()> { assert_eq!( expand_variables(&app.editor, "%{dirname}").unwrap(), - std::env::current_dir()?.to_str().unwrap() + helix_view::document::SCRATCH_BUFFER_NAME, ); } @@ -70,9 +70,9 @@ async fn test_variable_expansion() -> anyhow::Result<()> { let mut app = AppBuilder::new().with_file(file.path(), None).build()?; test_key_sequence( &mut app, - Some("ihelix%"), + Some("ihelixhelixhelix"), Some(&|app| { - assert_eq!(expand_variables(&app.editor, "%{linenumber}").unwrap(), "1"); + assert_eq!(expand_variables(&app.editor, "%{linenumber}").unwrap(), "4"); }), false, ) diff --git a/helix-view/src/editor.rs b/helix-view/src/editor.rs index 3fdade5f1118..de57fb437523 100644 --- a/helix-view/src/editor.rs +++ b/helix-view/src/editor.rs @@ -1,5 +1,5 @@ mod variable_expansion; -pub use variable_expansion::expand_variables; +pub use variable_expansion::{expand_variables, VARIABLES}; use crate::{ align_view, @@ -734,7 +734,7 @@ pub struct WhitespaceCharacters { impl Default for WhitespaceCharacters { fn default() -> Self { Self { - space: '·', // U+00B7 + space: '·', // U+00B7 nbsp: '⍽', // U+237D tab: '→', // U+2192 newline: '⏎', // U+23CE diff --git a/helix-view/src/editor/variable_expansion.rs b/helix-view/src/editor/variable_expansion.rs index 504360fd87b3..d49a5d32d48f 100644 --- a/helix-view/src/editor/variable_expansion.rs +++ b/helix-view/src/editor/variable_expansion.rs @@ -2,6 +2,16 @@ use std::borrow::Cow; use crate::Editor; +pub const VARIABLES: [&str; 7] = [ + "%sh{}", + "%{basename}", + "%{filename}", + "%{dirname}", + "%{cwd}", + "%{linenumber}", + "%{selection}", +]; + pub fn expand_variables<'a>(editor: &Editor, input: &'a str) -> anyhow::Result> { let (view, doc) = current_ref!(editor); let shell = &editor.config().shell; @@ -40,8 +50,9 @@ pub fn expand_variables<'a>(editor: &Editor, input: &'a str) -> anyhow::Result std::env::current_dir()?.to_str().unwrap().to_owned(), "linenumber" => (doc .selection(view.id) .primary() From f2672bfdf209dad4bdd3393b0df8758d4e77801d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20R?= Date: Mon, 15 Jan 2024 10:00:01 -0600 Subject: [PATCH 21/23] Generate docs and use helix's canonicalize --- book/src/generated/typable-cmd.md | 1 + helix-term/tests/test/commands/variable_expansion.rs | 10 ++++------ 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/book/src/generated/typable-cmd.md b/book/src/generated/typable-cmd.md index f4fcb6f6200e..ab90e122affc 100644 --- a/book/src/generated/typable-cmd.md +++ b/book/src/generated/typable-cmd.md @@ -86,3 +86,4 @@ | `:clear-register` | Clear given register. If no argument is provided, clear all registers. | | `:redraw` | Clear and re-render the whole UI | | `:move` | Move the current buffer and its corresponding file to a different path | +| `:echo` | Print the processed input to the editor status | diff --git a/helix-term/tests/test/commands/variable_expansion.rs b/helix-term/tests/test/commands/variable_expansion.rs index 6c8f8952c919..ad6e845694c1 100644 --- a/helix-term/tests/test/commands/variable_expansion.rs +++ b/helix-term/tests/test/commands/variable_expansion.rs @@ -4,7 +4,7 @@ use helix_view::editor::expand_variables; #[tokio::test(flavor = "multi_thread")] async fn test_variable_expansion() -> anyhow::Result<()> { { - let mut app = AppBuilder::new().build()?; + let app = AppBuilder::new().build()?; assert_eq!( expand_variables(&app.editor, "%{filename}").unwrap(), @@ -24,12 +24,11 @@ async fn test_variable_expansion() -> anyhow::Result<()> { { let file = tempfile::NamedTempFile::new()?; - let mut app = AppBuilder::new().with_file(file.path(), None).build()?; + let app = AppBuilder::new().with_file(file.path(), None).build()?; assert_eq!( expand_variables(&app.editor, "%{filename}").unwrap(), - std::fs::canonicalize(file.path().to_str().unwrap()) - .unwrap() + helix_core::path::get_canonicalized_path(file.path()) .to_str() .unwrap() ); @@ -41,8 +40,7 @@ async fn test_variable_expansion() -> anyhow::Result<()> { assert_eq!( expand_variables(&app.editor, "%{dirname}").unwrap(), - std::fs::canonicalize(file.path().parent().unwrap().to_str().unwrap()) - .unwrap() + helix_core::path::get_canonicalized_path(file.path().parent().unwrap()) .to_str() .unwrap() ); From 778db9b00978238a6e9c739b2fa0ac49400b34bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20R?= Date: Fri, 19 Jan 2024 12:03:12 -0600 Subject: [PATCH 22/23] Make requested changes and add usage docs --- book/src/usage.md | 21 ++ helix-term/src/commands.rs | 2 +- helix-term/src/commands/typed.rs | 43 +--- .../tests/test/commands/variable_expansion.rs | 128 +++++++--- helix-view/src/editor.rs | 2 +- helix-view/src/editor/variable_expansion.rs | 233 +++++++++--------- 6 files changed, 235 insertions(+), 194 deletions(-) diff --git a/book/src/usage.md b/book/src/usage.md index e01482193193..d0d83552cdf6 100644 --- a/book/src/usage.md +++ b/book/src/usage.md @@ -8,6 +8,7 @@ - [Selecting and manipulating text with textobjects](#selecting-and-manipulating-text-with-textobjects) - [Navigating using tree-sitter textobjects](#navigating-using-tree-sitter-textobjects) - [Moving the selection with syntax-aware motions](#moving-the-selection-with-syntax-aware-motions) +- [Using variables in typed commands and mapped shortcuts](#using-variables-in-typed-commands-and-mapped-shortcuts) For a full interactive introduction to Helix, refer to the @@ -203,6 +204,26 @@ sibling, the selection will move up the syntax tree and select the previous element. As a result, using `Alt-p` with a selection on `arg1` will move the selection to the "func" `identifier`. +## Using variables in typed commands and mapped shortcuts +Helix provides several variables that can be used when typing commands or creating custom shortcuts. These variables are listed below: + +| Variable | Description | +| --- | --- | +| `%{basename}` | The name and extension of the currently focused file. | +| `%{filename}` | The absolute path of the currently focused file. | +| `%{dirname}` | The absolute path of the parent directory of the currently focused file. | +| `%{cwd}` | The absolute path of the current working directory of Helix. | +| `%{linenumber}` | The line number where the main cursor is positioned. | +| `%{selection}` | The text selected by the main cursor. | +| `%sh{cmd}` | Executes `cmd` with the default shell and returns the command output, if any. | + +### Example +```toml +[keys.normal] +# Print blame info for the line where the main cursor is. +C-b = ":echo %sh{git blame -L %{linenumber} %{filename}}" +``` + [lang-support]: ./lang-support.md [unimpaired-keybinds]: ./keymap.md#unimpaired [tree-sitter-nav-demo]: https://user-images.githubusercontent.com/23398472/152332550-7dfff043-36a2-4aec-b8f2-77c13eb56d6f.gif diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index 2250fa145498..1570a2d242bb 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -205,7 +205,7 @@ impl MappableCommand { let args = args.join(" "); - match helix_view::editor::expand_variables(cx.editor, &args) { + match cx.editor.expand_variables(&args) { Ok(args) => { let args = args.split_whitespace(); let args: Vec> = args.map(Cow::Borrowed).collect(); diff --git a/helix-term/src/commands/typed.rs b/helix-term/src/commands/typed.rs index cc7400d89704..f9bb7a80de4e 100644 --- a/helix-term/src/commands/typed.rs +++ b/helix-term/src/commands/typed.rs @@ -2483,47 +2483,6 @@ fn echo(cx: &mut compositor::Context, args: &[Cow], event: PromptEvent) -> Ok(()) } -pub fn process_cmd( - cx: &mut compositor::Context, - input: &str, - event: PromptEvent, -) -> anyhow::Result<()> { - let input: Cow = if event == PromptEvent::Validate { - helix_view::editor::expand_variables(cx.editor, input)? - } else { - Cow::Borrowed(input) - }; - - let parts = input.split_whitespace().collect::>(); - if parts.is_empty() { - return Ok(()); - } - - // If command is numeric, interpret as line number and go there. - if parts.len() == 1 && parts[0].parse::().ok().is_some() { - if let Err(e) = typed::goto_line_number(cx, &[Cow::from(parts[0])], event) { - cx.editor.set_error(format!("{}", e)); - return Err(e); - } - return Ok(()); - } - - // Handle typable commands - if let Some(cmd) = typed::TYPABLE_COMMAND_MAP.get(parts[0]) { - let shellwords = shellwords::Shellwords::from(input.as_ref()); - let args = shellwords.words(); - - if let Err(e) = (cmd.fun)(cx, &args[1..], event) { - cx.editor.set_error(format!("{}", e)); - return Err(e); - } - } else if event == PromptEvent::Validate { - cx.editor - .set_error(format!("no such command: '{}'", parts[0])); - } - Ok(()) -} - pub const TYPABLE_COMMAND_LIST: &[TypableCommand] = &[ TypableCommand { name: "quit", @@ -3202,7 +3161,7 @@ pub(super) fn command_mode(cx: &mut Context) { }, // completion move |cx: &mut compositor::Context, input: &str, event: PromptEvent| { let input: Cow = if event == PromptEvent::Validate { - match helix_view::editor::expand_variables(cx.editor, input) { + match cx.editor.expand_variables(input) { Ok(args) => args, Err(e) => { cx.editor.set_error(format!("{}", e)); diff --git a/helix-term/tests/test/commands/variable_expansion.rs b/helix-term/tests/test/commands/variable_expansion.rs index ad6e845694c1..a0935c426c6e 100644 --- a/helix-term/tests/test/commands/variable_expansion.rs +++ b/helix-term/tests/test/commands/variable_expansion.rs @@ -1,49 +1,104 @@ use super::*; -use helix_view::editor::expand_variables; #[tokio::test(flavor = "multi_thread")] async fn test_variable_expansion() -> anyhow::Result<()> { { - let app = AppBuilder::new().build()?; + let mut app = AppBuilder::new().build()?; - assert_eq!( - expand_variables(&app.editor, "%{filename}").unwrap(), - helix_view::document::SCRATCH_BUFFER_NAME, - ); + test_key_sequence( + &mut app, + Some(":echo %{filename}"), + Some(&|app| { + assert_eq!( + app.editor.get_status().unwrap().0, + helix_view::document::SCRATCH_BUFFER_NAME + ); + }), + false, + ) + .await?; + + let mut app = AppBuilder::new().build()?; + + test_key_sequence( + &mut app, + Some(":echo %{basename}"), + Some(&|app| { + assert_eq!( + app.editor.get_status().unwrap().0, + helix_view::document::SCRATCH_BUFFER_NAME + ); + }), + false, + ) + .await?; - assert_eq!( - expand_variables(&app.editor, "%{basename}").unwrap(), - helix_view::document::SCRATCH_BUFFER_NAME, - ); + let mut app = AppBuilder::new().build()?; - assert_eq!( - expand_variables(&app.editor, "%{dirname}").unwrap(), - helix_view::document::SCRATCH_BUFFER_NAME, - ); + test_key_sequence( + &mut app, + Some(":echo %{dirname}"), + Some(&|app| { + assert_eq!( + app.editor.get_status().unwrap().0, + helix_view::document::SCRATCH_BUFFER_NAME + ); + }), + false, + ) + .await?; } { let file = tempfile::NamedTempFile::new()?; - let app = AppBuilder::new().with_file(file.path(), None).build()?; + let mut app = AppBuilder::new().with_file(file.path(), None).build()?; - assert_eq!( - expand_variables(&app.editor, "%{filename}").unwrap(), - helix_core::path::get_canonicalized_path(file.path()) - .to_str() - .unwrap() - ); + test_key_sequence( + &mut app, + Some(":echo %{filename}"), + Some(&|app| { + assert_eq!( + app.editor.get_status().unwrap().0, + helix_stdx::path::canonicalize(file.path()) + .to_str() + .unwrap() + ); + }), + false, + ) + .await?; - assert_eq!( - expand_variables(&app.editor, "%{basename}").unwrap(), - file.path().file_name().unwrap().to_str().unwrap() - ); + let mut app = AppBuilder::new().with_file(file.path(), None).build()?; - assert_eq!( - expand_variables(&app.editor, "%{dirname}").unwrap(), - helix_core::path::get_canonicalized_path(file.path().parent().unwrap()) - .to_str() - .unwrap() - ); + test_key_sequence( + &mut app, + Some(":echo %{basename}"), + Some(&|app| { + assert_eq!( + app.editor.get_status().unwrap().0, + file.path().file_name().unwrap().to_str().unwrap() + ); + }), + false, + ) + .await?; + + let mut app = AppBuilder::new().with_file(file.path(), None).build()?; + + test_key_sequence( + &mut app, + Some(":echo %{dirname}"), + Some(&|app| { + assert_eq!( + app.editor.get_status().unwrap().0, + helix_stdx::path::canonicalize(file.path().parent().unwrap()) + .to_str() + .unwrap() + ); + }), + false, + ) + .await?; } { @@ -51,12 +106,9 @@ async fn test_variable_expansion() -> anyhow::Result<()> { let mut app = AppBuilder::new().with_file(file.path(), None).build()?; test_key_sequence( &mut app, - Some("ihelix%"), + Some("ihelix%:echo %{selection}"), Some(&|app| { - assert_eq!( - expand_variables(&app.editor, "%{selection}").unwrap(), - "helix" - ); + assert_eq!(app.editor.get_status().unwrap().0, "helix"); }), false, ) @@ -68,9 +120,9 @@ async fn test_variable_expansion() -> anyhow::Result<()> { let mut app = AppBuilder::new().with_file(file.path(), None).build()?; test_key_sequence( &mut app, - Some("ihelixhelixhelix"), + Some("ihelixhelixhelix:echo %{linenumber}"), Some(&|app| { - assert_eq!(expand_variables(&app.editor, "%{linenumber}").unwrap(), "4"); + assert_eq!(app.editor.get_status().unwrap().0, "4"); }), false, ) diff --git a/helix-view/src/editor.rs b/helix-view/src/editor.rs index de57fb437523..e614acae5dd9 100644 --- a/helix-view/src/editor.rs +++ b/helix-view/src/editor.rs @@ -1,5 +1,5 @@ mod variable_expansion; -pub use variable_expansion::{expand_variables, VARIABLES}; +pub use variable_expansion::VARIABLES; use crate::{ align_view, diff --git a/helix-view/src/editor/variable_expansion.rs b/helix-view/src/editor/variable_expansion.rs index d49a5d32d48f..34fd90dd7d40 100644 --- a/helix-view/src/editor/variable_expansion.rs +++ b/helix-view/src/editor/variable_expansion.rs @@ -12,138 +12,147 @@ pub const VARIABLES: [&str; 7] = [ "%{selection}", ]; -pub fn expand_variables<'a>(editor: &Editor, input: &'a str) -> anyhow::Result> { - let (view, doc) = current_ref!(editor); - let shell = &editor.config().shell; - - let mut output: Option = None; - - let mut chars = input.char_indices(); - let mut last_push_end: usize = 0; - - while let Some((index, char)) = chars.next() { - if char == '%' { - if let Some((_, char)) = chars.next() { - if char == '{' { - for (end, char) in chars.by_ref() { - if char == '}' { - if output.is_none() { - output = Some(String::with_capacity(input.len())) - } +impl Editor { + pub fn expand_variables<'a>(&self, input: &'a str) -> anyhow::Result> { + let (view, doc) = current_ref!(self); + let shell = &self.config().shell; - if let Some(o) = output.as_mut() { - o.push_str(&input[last_push_end..index]); - last_push_end = end + 1; - - let value = match &input[index + 2..end] { - "basename" => doc - .path() - .and_then(|it| it.file_name().and_then(|it| it.to_str())) - .unwrap_or(crate::document::SCRATCH_BUFFER_NAME) - .to_owned(), - "filename" => doc - .path() - .and_then(|it| it.to_str()) - .unwrap_or(crate::document::SCRATCH_BUFFER_NAME) - .to_owned(), - "dirname" => doc - .path() - .and_then(|p| p.parent()) - .and_then(std::path::Path::to_str) - .unwrap_or(crate::document::SCRATCH_BUFFER_NAME) - .to_owned(), - "cwd" => std::env::current_dir()?.to_str().unwrap().to_owned(), - "linenumber" => (doc - .selection(view.id) - .primary() - .cursor_line(doc.text().slice(..)) - + 1) - .to_string(), - "selection" => doc - .selection(view.id) - .primary() - .fragment(doc.text().slice(..)) - .to_string(), - _ => anyhow::bail!("Unknown variable"), - }; + let mut output: Option = None; - o.push_str(value.trim()); + let mut chars = input.char_indices(); + let mut last_push_end: usize = 0; - break; - } - } - } - } else if char == 's' { - if let (Some((_, 'h')), Some((_, '{'))) = (chars.next(), chars.next()) { - let mut right_bracket_remaining = 1; + while let Some((index, char)) = chars.next() { + if char == '%' { + if let Some((_, char)) = chars.next() { + if char == '{' { for (end, char) in chars.by_ref() { if char == '}' { - right_bracket_remaining -= 1; - - if right_bracket_remaining == 0 { - if output.is_none() { - output = Some(String::with_capacity(input.len())) - } + if output.is_none() { + output = Some(String::with_capacity(input.len())) + } - if let Some(o) = output.as_mut() { - let body = - expand_variables(editor, &input[index + 4..end])?; + if let Some(o) = output.as_mut() { + o.push_str(&input[last_push_end..index]); + last_push_end = end + 1; - let output = tokio::task::block_in_place(move || { - helix_lsp::block_on(async move { - let mut command = - tokio::process::Command::new(&shell[0]); - command.args(&shell[1..]).arg(&body[..]); + let value = match &input[index + 2..end] { + "basename" => doc + .path() + .and_then(|it| { + it.file_name().and_then(|it| it.to_str()) + }) + .unwrap_or(crate::document::SCRATCH_BUFFER_NAME) + .to_owned(), + "filename" => doc + .path() + .and_then(|it| it.to_str()) + .unwrap_or(crate::document::SCRATCH_BUFFER_NAME) + .to_owned(), + "dirname" => doc + .path() + .and_then(|p| p.parent()) + .and_then(std::path::Path::to_str) + .unwrap_or(crate::document::SCRATCH_BUFFER_NAME) + .to_owned(), + "cwd" => helix_stdx::env::current_working_dir() + .to_str() + .unwrap() + .to_owned(), + "linenumber" => (doc + .selection(view.id) + .primary() + .cursor_line(doc.text().slice(..)) + + 1) + .to_string(), + "selection" => doc + .selection(view.id) + .primary() + .fragment(doc.text().slice(..)) + .to_string(), + _ => anyhow::bail!("Unknown variable"), + }; - let output = - command.output().await.map_err(|_| { - anyhow::anyhow!( - "Shell command failed: {body}" - ) - })?; + o.push_str(value.trim()); - if output.status.success() { - String::from_utf8(output.stdout).map_err(|_| { - anyhow::anyhow!( + break; + } + } + } + } else if char == 's' { + if let (Some((_, 'h')), Some((_, '{'))) = (chars.next(), chars.next()) { + let mut right_bracket_remaining = 1; + for (end, char) in chars.by_ref() { + if char == '}' { + right_bracket_remaining -= 1; + + if right_bracket_remaining == 0 { + if output.is_none() { + output = Some(String::with_capacity(input.len())) + } + + if let Some(o) = output.as_mut() { + let body = + self.expand_variables(&input[index + 4..end])?; + + let output = tokio::task::block_in_place(move || { + helix_lsp::block_on(async move { + let mut command = + tokio::process::Command::new(&shell[0]); + command.args(&shell[1..]).arg(&body[..]); + + let output = + command.output().await.map_err(|_| { + anyhow::anyhow!( + "Shell command failed: {body}" + ) + })?; + + if output.status.success() { + String::from_utf8(output.stdout).map_err( + |_| { + anyhow::anyhow!( "Process did not output valid UTF-8" ) - }) - } else if output.stderr.is_empty() { - Err(anyhow::anyhow!( - "Shell command failed: {body}" - )) - } else { - let stderr = - String::from_utf8_lossy(&output.stderr); - - Err(anyhow::anyhow!("{stderr}")) - } - }) - }); - o.push_str(&input[last_push_end..index]); - last_push_end = end + 1; - - o.push_str(output?.trim()); - - break; + }, + ) + } else if output.stderr.is_empty() { + Err(anyhow::anyhow!( + "Shell command failed: {body}" + )) + } else { + let stderr = + String::from_utf8_lossy(&output.stderr); + + Err(anyhow::anyhow!("{stderr}")) + } + }) + }); + o.push_str(&input[last_push_end..index]); + last_push_end = end + 1; + + o.push_str(output?.trim()); + + break; + } } + } else if char == '{' { + right_bracket_remaining += 1; } - } else if char == '{' { - right_bracket_remaining += 1; } } } } } } - } - if let Some(o) = output.as_mut() { - o.push_str(&input[last_push_end..]); - } + if let Some(o) = output.as_mut() { + o.push_str(&input[last_push_end..]); + } - match output { - Some(o) => Ok(std::borrow::Cow::Owned(o)), - None => Ok(std::borrow::Cow::Borrowed(input)), + match output { + Some(o) => Ok(Cow::Owned(o)), + None => Ok(Cow::Borrowed(input)), + } } } From efe6aef76cc26df123ebe789541e43c44f533c32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20R?= Date: Fri, 19 Jan 2024 15:17:22 -0600 Subject: [PATCH 23/23] main cursor to primary cursor --- book/src/usage.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/book/src/usage.md b/book/src/usage.md index d0d83552cdf6..ea25c1e3243b 100644 --- a/book/src/usage.md +++ b/book/src/usage.md @@ -213,8 +213,8 @@ Helix provides several variables that can be used when typing commands or creati | `%{filename}` | The absolute path of the currently focused file. | | `%{dirname}` | The absolute path of the parent directory of the currently focused file. | | `%{cwd}` | The absolute path of the current working directory of Helix. | -| `%{linenumber}` | The line number where the main cursor is positioned. | -| `%{selection}` | The text selected by the main cursor. | +| `%{linenumber}` | The line number where the primary cursor is positioned. | +| `%{selection}` | The text selected by the primary cursor. | | `%sh{cmd}` | Executes `cmd` with the default shell and returns the command output, if any. | ### Example