From 7fd67d7b17d53bd08653f2389ad36725d65dfa46 Mon Sep 17 00:00:00 2001 From: Rolo Date: Mon, 23 Dec 2024 03:36:44 -0800 Subject: [PATCH 01/16] feat: add initial structs for custom command --- helix-view/src/commands/custom.rs | 165 ++++++++++++++++++++++++++++++ helix-view/src/commands/mod.rs | 1 + helix-view/src/lib.rs | 1 + 3 files changed, 167 insertions(+) create mode 100644 helix-view/src/commands/custom.rs create mode 100644 helix-view/src/commands/mod.rs diff --git a/helix-view/src/commands/custom.rs b/helix-view/src/commands/custom.rs new file mode 100644 index 000000000000..b427bf7b4e28 --- /dev/null +++ b/helix-view/src/commands/custom.rs @@ -0,0 +1,165 @@ +// If a combination takes arguments, say with a `%{arg}` syntax, it could be positional +// so that an Args could just run a simple `next` and replace as it encounters them. +// +// For example: +// "wcd!" = [":write --force %{arg}", ":cd %sh{ %{arg} | path dirname}"] +// +// Would this take arguments like: +// :wcd! /repo/sub/sub/file.txt +// $ :write --force repo/sub/sub/file -> cd /repo/sub/sub/ +// +// Also want to be able to support prompt usage, allowing the ability to add prompt options in the +// config: +// "wcd!" = { +// commands = [":write --force %{arg}", ":cd %sh{ %{arg} | path dirname}"], +// desc= "writes buffer forcefully, then changes to its directory" +// # path would be a hardcoded completion option. +// # Might also be able to name other commands to get there completions? +// # completions = "write" +// completions = "path" +// # allow custom list of completions +// completions = [ +// "foo", +// "bar", +// "baz", +// ] +// +// TODO: mention the handling of optionl and required arguments, and that it will just be forwarded to the command +// and any checking it has itself. +// [commands.wcd!] +// commands = [":write --force %{arg}", ":cd %sh{ %{arg} | path dirname }"] +// desc = "writes buffer forcefully, then changes to its directory" +// completions = "write" +// accepts = "" +// +// %{arg} and %{arg:0} are equivalent. +// These represent arguments passed down from the command call. +// As this will call `Args::nth(arg:NUM)`, you can just number them from 0.. +// to access the corresponding arg. +// +// TODO: When adding custom aliases to the command prompt list, must priotize the custom over the built-in. +// Should include removing the alias from the aliases command? + +use serde::{Deserialize, Serialize}; + +// TODO: Might need to manually implement Serialize and Deserialize +#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)] +pub struct Commands { + pub commands: Vec, +} + +impl Commands { + #[inline] + #[must_use] + pub fn get(&self, name: &str) -> Option<&Command> { + self.commands.iter().find(|command| command.name == name) + } + + #[inline] + pub fn names(&self) -> impl Iterator { + self.commands.iter().map(|command| command.name.as_str()) + } +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +pub struct Command { + pub name: String, + pub desc: Option, + pub commands: Vec, + pub accepts: Option, + pub completer: Option, +} + +impl Command { + pub fn prompt(&self) -> String { + // wcd! : writes buffer forcefully, then changes to its directory + // + // maps: + // :write --force %{arg} -> :cd %sh{ %{arg} | path dirname } + todo!() + } + + pub fn iter(&self) -> impl Iterator { + self.commands.iter().map(String::as_str) + } +} + +// TODO: Need to get access to a new table in the config: [commands]. +// TODO: If anycommands can be supported, then can add a check for `MappableCommand::STATIC_COMMAND_LIST`. +// Might also need to make a `MappableCommand::STATIC_COMMAND_MAP`. +// +// Could also just add support directly to `MappableCommand::from_str`. This would then allow a `command.execute` +// and support macros as well. + +// Checking against user provided commands first gives priority +// to user defined aliases over the built-in, allowing for overriding. +// if let Some(custom: &CustomTypeableCommand) = cx.editor.config.load().commands.get(name) { +// for command: &str in custom.commands.iter() { +// let shellwords = Shellwords::from(command); +// +// if let Some(command: &TypeableCommand) = typed::TYPABLE_COMMAND_MAP.get(shellwords.command()) { +// // TODO: Add parsing support for u%{arg:NUM}` in `expand`. +// let args = match variables::expand(cx.editor, shellwords.args().raw(), event == PromptEvent::Validate) { +// Ok(args) => args, +// Err(err) => { +// cx.editor.set_error(format!("{err}")); +// // short circuit if error +// return; +// }, +// } +// +// if let Err(err) = (command.fun)(cx, Args::from(&args), command.flags, event) { +// cx.editor.set_error(format!("{err}")); +// // short circuit if error +// return; +// } +// } else { +// cx.editor.set_error(format!("command `:{}` is not a valid command", shellwords.command())); +// // short circuit if error +// return; +// } +// } else if let Some(command) = typed::TYPABLE_COMMAND_MAP.get(shellwords.command()) { +// // Current impl +// } +// +// +// TODO: Could add an `aliases` to `CustomTypableCommand` and then add those as well? +// Prompt: +// +// fuzzy_match( +// input, +// // `chain` the names that are yielded buy the iterator. +// TYPABLE_COMMAND_LIST.iter().map(|command| command.name).chain(editor.config.load().commands.iter()), +// false, +// ) +// .into_iter() +// .map(|(name, _)| (0.., name.into())) +// .collect() +// +// +// Completer: +// +// let command = = editor +// .config +// .load() +// .commands +// .get(command) +// .map(|command | command.completer) +// .unwrap_or(command); +// +// TYPABLE_COMMAND_MAP +// .get(command) +// .map(|tc| tc.completer_for_argument_number(argument_number_of(&shellwords))) +// .map_or_else(Vec::new, |completer| { +// completer(editor, word) +// .into_iter() +// .map(|(range, mut file)| { +// file.content = shellwords::escape(file.content); +// +// // offset ranges to input +// let offset = input.len() - len; +// let range = (range.start + offset)..; +// (range, file) +// }) +// .collect() +// }) diff --git a/helix-view/src/commands/mod.rs b/helix-view/src/commands/mod.rs new file mode 100644 index 000000000000..d5f905683aa7 --- /dev/null +++ b/helix-view/src/commands/mod.rs @@ -0,0 +1 @@ +pub mod custom; diff --git a/helix-view/src/lib.rs b/helix-view/src/lib.rs index d54b49ef5400..218662640ee3 100644 --- a/helix-view/src/lib.rs +++ b/helix-view/src/lib.rs @@ -4,6 +4,7 @@ pub mod macros; pub mod annotations; pub mod base64; pub mod clipboard; +pub mod commands; pub mod document; pub mod editor; pub mod events; From 291d064ea82dace54bb740a60d244c80b6da5165 Mon Sep 17 00:00:00 2001 From: Rolo Date: Mon, 23 Dec 2024 03:37:09 -0800 Subject: [PATCH 02/16] feat: add cutom commands to editor config --- helix-view/src/editor.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/helix-view/src/editor.rs b/helix-view/src/editor.rs index 6c585a8a7f2c..8f9030e9de3a 100644 --- a/helix-view/src/editor.rs +++ b/helix-view/src/editor.rs @@ -1,6 +1,7 @@ use crate::{ annotations::diagnostics::{DiagnosticFilter, InlineDiagnosticsConfig}, clipboard::ClipboardProvider, + commands::custom::Commands, document::{ DocumentOpenError, DocumentSavedEventFuture, DocumentSavedEventResult, Mode, SavePoint, }, @@ -360,6 +361,8 @@ pub struct Config { pub end_of_line_diagnostics: DiagnosticFilter, // Set to override the default clipboard provider pub clipboard_provider: ClipboardProvider, + /// Custom typeable commands + pub commands: Commands, } #[derive(Debug, Clone, PartialEq, Deserialize, Serialize, Eq, PartialOrd, Ord)] @@ -1001,6 +1004,7 @@ impl Default for Config { inline_diagnostics: InlineDiagnosticsConfig::default(), end_of_line_diagnostics: DiagnosticFilter::Disable, clipboard_provider: ClipboardProvider::default(), + commands: Commands::default(), } } } From 99b60754080121e6fbcea6e98a6e3d59889b9288 Mon Sep 17 00:00:00 2001 From: Rolo Date: Mon, 23 Dec 2024 03:38:44 -0800 Subject: [PATCH 03/16] feat: inital prompt support Unfortunate happening with lifetimes here. Also supports getting the commands from the custom label and running them as typable commands. --- helix-term/src/commands/typed.rs | 55 ++++++++++++++++++++++++++------ 1 file changed, 45 insertions(+), 10 deletions(-) diff --git a/helix-term/src/commands/typed.rs b/helix-term/src/commands/typed.rs index 27e2c75d71f8..71f33a31b7a8 100644 --- a/helix-term/src/commands/typed.rs +++ b/helix-term/src/commands/typed.rs @@ -1,6 +1,7 @@ use std::fmt::Write; use std::io::BufReader; use std::ops::Deref; +use std::sync::Arc; use crate::job::Job; @@ -3059,24 +3060,33 @@ pub static TYPABLE_COMMAND_MAP: Lazy>(); + if command.is_empty() || (shellwords.args().next().is_none() && !shellwords.ends_with_whitespace()) { - fuzzy_match( - input, - TYPABLE_COMMAND_LIST.iter().map(|command| command.name), - false, - ) - .into_iter() - .map(|(name, _)| (0.., name.into())) - .collect() + fuzzy_match(input, items, false) + .into_iter() + .map(|(name, _)| (0.., name.into())) + .collect() } else { // Otherwise, use the command's completer and the last shellword // as completion input. @@ -3085,6 +3095,10 @@ pub(super) fn command_mode(cx: &mut Context) { .last() .map_or(("", 0), |last| (last, last.len())); + let command = commands.get(command).map_or(command, |c| { + c.completer.as_ref().map_or(command, String::as_str) + }); + TYPABLE_COMMAND_MAP .get(command) .map(|tc| tc.completer_for_argument_number(argument_number_of(&shellwords))) @@ -3119,8 +3133,29 @@ pub(super) fn command_mode(cx: &mut Context) { return; } + // Checking for custom commands first priotizes custom commands over built-in/ + if let Some(custom) = cx.editor.config().commands.get(command) { + for command in custom.iter() { + // TODO: Expand args: #11164 + + let shellwords = Shellwords::from(command); + + if let Some(command) = typed::TYPABLE_COMMAND_MAP.get(shellwords.command()) { + if let Err(err) = (command.fun)(cx, shellwords.args(), event) { + cx.editor.set_error(format!("{err}")); + // Short circuit on error + return; + } + } else if event == PromptEvent::Validate { + cx.editor + .set_error(format!("no such command: '{}'", shellwords.command())); + // Short circuit on error + return; + } + } + } // Handle typable commands - if let Some(cmd) = typed::TYPABLE_COMMAND_MAP.get(command) { + else if let Some(cmd) = typed::TYPABLE_COMMAND_MAP.get(command) { if let Err(err) = (cmd.fun)(cx, shellwords.args(), event) { cx.editor.set_error(format!("{err}")); } From a090960a075bb52245fb78a2b54b1cd3592847f9 Mon Sep 17 00:00:00 2001 From: Rolo Date: Mon, 23 Dec 2024 04:34:09 -0800 Subject: [PATCH 04/16] refactor: rename commands structs --- helix-view/src/commands/custom.rs | 12 ++++++------ helix-view/src/editor.rs | 6 +++--- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/helix-view/src/commands/custom.rs b/helix-view/src/commands/custom.rs index b427bf7b4e28..024d89af33d5 100644 --- a/helix-view/src/commands/custom.rs +++ b/helix-view/src/commands/custom.rs @@ -44,14 +44,14 @@ use serde::{Deserialize, Serialize}; // TODO: Might need to manually implement Serialize and Deserialize #[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)] -pub struct Commands { - pub commands: Vec, +pub struct CustomTypeableCommands { + pub commands: Vec, } -impl Commands { +impl CustomTypeableCommands { #[inline] #[must_use] - pub fn get(&self, name: &str) -> Option<&Command> { + pub fn get(&self, name: &str) -> Option<&CustomTypableCommand> { self.commands.iter().find(|command| command.name == name) } @@ -62,7 +62,7 @@ impl Commands { } #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] -pub struct Command { +pub struct CustomTypableCommand { pub name: String, pub desc: Option, pub commands: Vec, @@ -70,7 +70,7 @@ pub struct Command { pub completer: Option, } -impl Command { +impl CustomTypableCommand { pub fn prompt(&self) -> String { // wcd! : writes buffer forcefully, then changes to its directory // diff --git a/helix-view/src/editor.rs b/helix-view/src/editor.rs index 8f9030e9de3a..2e648302c281 100644 --- a/helix-view/src/editor.rs +++ b/helix-view/src/editor.rs @@ -1,7 +1,7 @@ use crate::{ annotations::diagnostics::{DiagnosticFilter, InlineDiagnosticsConfig}, clipboard::ClipboardProvider, - commands::custom::Commands, + commands::custom::CustomTypeableCommands, document::{ DocumentOpenError, DocumentSavedEventFuture, DocumentSavedEventResult, Mode, SavePoint, }, @@ -362,7 +362,7 @@ pub struct Config { // Set to override the default clipboard provider pub clipboard_provider: ClipboardProvider, /// Custom typeable commands - pub commands: Commands, + pub commands: CustomTypeableCommands, } #[derive(Debug, Clone, PartialEq, Deserialize, Serialize, Eq, PartialOrd, Ord)] @@ -1004,7 +1004,7 @@ impl Default for Config { inline_diagnostics: InlineDiagnosticsConfig::default(), end_of_line_diagnostics: DiagnosticFilter::Disable, clipboard_provider: ClipboardProvider::default(), - commands: Commands::default(), + commands: CustomTypeableCommands::default(), } } } From 991228a17c3d24b09034b27af8b8e29f85692387 Mon Sep 17 00:00:00 2001 From: Rolo Date: Mon, 23 Dec 2024 04:34:29 -0800 Subject: [PATCH 05/16] fix: casing --- helix-term/src/commands/typed.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/helix-term/src/commands/typed.rs b/helix-term/src/commands/typed.rs index 71f33a31b7a8..1531835cf096 100644 --- a/helix-term/src/commands/typed.rs +++ b/helix-term/src/commands/typed.rs @@ -3060,7 +3060,7 @@ pub static TYPABLE_COMMAND_MAP: Lazy Date: Mon, 23 Dec 2024 10:26:44 -0800 Subject: [PATCH 06/16] feat: get a basic backend working --- helix-term/src/commands/typed.rs | 35 +++++++++++++++++++++++-------- helix-view/src/commands/custom.rs | 31 +++++++++++++++++++++++---- 2 files changed, 53 insertions(+), 13 deletions(-) diff --git a/helix-term/src/commands/typed.rs b/helix-term/src/commands/typed.rs index 1531835cf096..237ee5ad3410 100644 --- a/helix-term/src/commands/typed.rs +++ b/helix-term/src/commands/typed.rs @@ -3058,11 +3058,12 @@ pub static TYPABLE_COMMAND_MAP: Lazy>()); + + if let Err(err) = (typed_command.fun)(cx, args, event) { cx.editor.set_error(format!("{err}")); // Short circuit on error return; @@ -3165,10 +3178,14 @@ pub(super) fn command_mode(cx: &mut Context) { }, ); - prompt.doc_fn = Box::new(|input: &str| { + prompt.doc_fn = Box::new(move |input: &str| { let shellwords = Shellwords::from(input); - if let Some(typed::TypableCommand { doc, aliases, .. }) = + if let Some(command) = custom_commands.clone().get(input) { + if let Some(desc) = &command.desc { + return Some(desc.clone().into()); + } + } else if let Some(typed::TypableCommand { doc, aliases, .. }) = typed::TYPABLE_COMMAND_MAP.get(shellwords.command()) { if aliases.is_empty() { diff --git a/helix-view/src/commands/custom.rs b/helix-view/src/commands/custom.rs index 024d89af33d5..bbf047a154a4 100644 --- a/helix-view/src/commands/custom.rs +++ b/helix-view/src/commands/custom.rs @@ -43,21 +43,42 @@ use serde::{Deserialize, Serialize}; // TODO: Might need to manually implement Serialize and Deserialize -#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)] +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] pub struct CustomTypeableCommands { pub commands: Vec, } +impl Default for CustomTypeableCommands { + fn default() -> Self { + Self { + commands: vec![CustomTypableCommand { + name: String::from(":lg"), + desc: Some(String::from("runs lazygit in a floating pane")), + commands: vec![String::from( + ":sh wezterm cli spawn --floating-pane lazygit", + )], + accepts: None, + completer: None, + }], + } + } +} + impl CustomTypeableCommands { #[inline] #[must_use] pub fn get(&self, name: &str) -> Option<&CustomTypableCommand> { - self.commands.iter().find(|command| command.name == name) + self.commands + .iter() + .find(|command| command.name.trim_start_matches(':') == name.trim_start_matches(':')) } #[inline] pub fn names(&self) -> impl Iterator { - self.commands.iter().map(|command| command.name.as_str()) + self.commands + .iter() + // ":wbc!" -> "wbc!" + .map(|command| command.name.as_str().trim_start_matches(':')) } } @@ -80,7 +101,9 @@ impl CustomTypableCommand { } pub fn iter(&self) -> impl Iterator { - self.commands.iter().map(String::as_str) + self.commands + .iter() + .map(|command| command.trim_start_matches(':')) } } From a4652490d201d17be29e494424e9b13b6ed0a836 Mon Sep 17 00:00:00 2001 From: Rolo Date: Tue, 24 Dec 2024 00:15:46 -0800 Subject: [PATCH 07/16] refactor: add validator for `%{arg[:NUMBER]}` pattern --- helix-term/src/commands/typed.rs | 130 +++++++++++++++++++++++++++++-- 1 file changed, 122 insertions(+), 8 deletions(-) diff --git a/helix-term/src/commands/typed.rs b/helix-term/src/commands/typed.rs index 237ee5ad3410..29775fe8d1f8 100644 --- a/helix-term/src/commands/typed.rs +++ b/helix-term/src/commands/typed.rs @@ -3061,9 +3061,9 @@ pub static TYPABLE_COMMAND_MAP: Lazy) => args, + // Err(err) => { + // cx.editor.set_error(format!("{err}")); + // // Short circuit on error + // return; + // } + // } + // if let Some(typed_command) = typed::TYPABLE_COMMAND_MAP.get(Shellwords::from(command).command()) { - // TODO: More advanced merging of arguments - // If command being typed has no args, assume that they come from custom command - let args = if shellwords.args().is_empty() { + // TEST: should allow for an option `%{arg}` even if no path is path is provided and work as if + // the `%{arg}` eas never present. + // + // Assume that if the command contains an `%{arg[:NUMBER]}` it will be accepting arguments from + // input and therefore not standalone. + // + // If `true`, then will use any arguments the command itself may have been written + // with and ignore any typed-in arguments. + // + // This is a special case for when config has simplest usage: + // + // "ww" = ":write --force" + // + // It will only use: `--force` as arguments when running `:write`. + // + // This also means that users dont have to explicitly use `%{arg}` every time: + // + // "ww" = ":write --force %{arg}" + // + // Though in the case of `:write`, they probably should. + // + // Regardless, some commands explicitly take zero arguments and this check should prevent + // input arguments being passed when they shouldnt. + // + // If `false`, then will assume that that command was passed arguments in expansion and that + // whats left is the full argument list to be sent run. + let args = if is_standalone(command) { Shellwords::from(command).args() } else { + // Input args + // TODO: Args::from(&args) from the expanded variables. shellwords.args() }; - log::error!("input: {:?}", args.clone().collect::>()); if let Err(err) = (typed_command.fun)(cx, args, event) { cx.editor.set_error(format!("{err}")); @@ -3161,7 +3196,7 @@ pub(super) fn command_mode(cx: &mut Context) { } } else if event == PromptEvent::Validate { cx.editor - .set_error(format!("no such command: '{}'", shellwords.command())); + .set_error(format!("no such command: '{}'", command)); // Short circuit on error return; } @@ -3178,10 +3213,11 @@ pub(super) fn command_mode(cx: &mut Context) { }, ); + let commands = arced.clone(); prompt.doc_fn = Box::new(move |input: &str| { let shellwords = Shellwords::from(input); - if let Some(command) = custom_commands.clone().get(input) { + if let Some(command) = commands.clone().get(input) { if let Some(desc) = &command.desc { return Some(desc.clone().into()); } @@ -3191,6 +3227,7 @@ pub(super) fn command_mode(cx: &mut Context) { if aliases.is_empty() { return Some((*doc).into()); } + return Some(format!("{}\nAliases: {}", doc, aliases.join(", ")).into()); } @@ -3209,6 +3246,64 @@ fn argument_number_of(shellwords: &Shellwords) -> usize { .saturating_sub(1 - usize::from(shellwords.ends_with_whitespace())) } +// NOTE: Only used in one spot +#[inline(always)] +fn is_standalone(command: &str) -> bool { + let mut idx = 0; + let bytes = command.as_bytes(); + + while idx < bytes.len() { + if bytes[idx] == b'%' { + if let Some(next) = bytes.get(idx + 1) { + if *next == b'{' { + // advance beyond `{` + idx += 2; + if let Some(arg) = bytes.get(idx..) { + match arg { + [b'a', b'r', b'g', b':', ..] => { + // Advance beyond the `:` + idx += 4; + + let mut is_prev_digit = false; + + for byte in &bytes[idx..] { + // Found end of arg bracket + if *byte == b'}' && is_prev_digit { + return false; + } + + if char::from(*byte).is_ascii_digit() { + is_prev_digit = true; + } else { + break; + } + } + } + [b'a', b'r', b'g', b'}', ..] => { + return false; + } + _ => { + idx += 2 + 4; + continue; + } + } + } + idx += 1; + continue; + } + idx += 1; + continue; + } + idx += 1; + continue; + } + idx += 1; + continue; + } + + true +} + #[test] fn test_argument_number_of() { let cases = vec![ @@ -3226,3 +3321,22 @@ fn test_argument_number_of() { assert_eq!(case.1, argument_number_of(&Shellwords::from(case.0))); } } + +#[test] +fn should_indicate_if_command_is_standalone() { + assert!(is_standalone("write --force")); + + // Must provide digit + assert!(is_standalone("write --force %{arg:}")); + + // Muts have `:` before digits can be added + assert!(is_standalone("write --force %{arg122444}")); + + // Must have closing bracket + assert!(is_standalone("write --force %{arg")); + assert!(is_standalone("write --force %{arg:1")); + + // Has valid variable + assert!(!is_standalone("write --force %{arg}")); + assert!(!is_standalone("write --force %{arg:1083472348978}")); +} From 96911f7e6a0f480b17445a2706476d18f3addaad Mon Sep 17 00:00:00 2001 From: Rolo Date: Tue, 24 Dec 2024 08:24:23 -0800 Subject: [PATCH 08/16] refactor: move command name into format! brackets --- helix-term/src/commands/typed.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/helix-term/src/commands/typed.rs b/helix-term/src/commands/typed.rs index 29775fe8d1f8..e8a9d0fb5d81 100644 --- a/helix-term/src/commands/typed.rs +++ b/helix-term/src/commands/typed.rs @@ -3195,8 +3195,7 @@ pub(super) fn command_mode(cx: &mut Context) { return; } } else if event == PromptEvent::Validate { - cx.editor - .set_error(format!("no such command: '{}'", command)); + cx.editor.set_error(format!("no such command: '{command}'")); // Short circuit on error return; } From 98287f36d4718dc6996fb7f342839d936ed14cd0 Mon Sep 17 00:00:00 2001 From: Rolo Date: Tue, 24 Dec 2024 21:56:45 -0800 Subject: [PATCH 09/16] feat: support running static commands and macros --- helix-term/src/commands/typed.rs | 119 +++++++++++++++++++++++-------- 1 file changed, 91 insertions(+), 28 deletions(-) diff --git a/helix-term/src/commands/typed.rs b/helix-term/src/commands/typed.rs index e8a9d0fb5d81..d6374305d2a2 100644 --- a/helix-term/src/commands/typed.rs +++ b/helix-term/src/commands/typed.rs @@ -3138,23 +3138,32 @@ pub(super) fn command_mode(cx: &mut Context) { return; } + // Protects against weird recursion issues in expansion. + // Positional arguments are only meant for use in the config. + if contains_arg_variable(shellwords.args().raw()) { + cx.editor.set_error( + "Input arguments cannot contain a positional argument variable: `%{arg[:INT]}`", + ); + } + // Checking for custom commands first priotizes custom commands over built-in/ if let Some(custom) = cx.editor.config().commands.get(command) { for command in custom.iter() { - // TODO: Expand variables: #11164 - // let args = match variables::expand(...) { - // Ok(args: Cow<'_, str>) => args, - // Err(err) => { - // cx.editor.set_error(format!("{err}")); - // // Short circuit on error - // return; - // } - // } - // - if let Some(typed_command) = typed::TYPABLE_COMMAND_MAP.get(Shellwords::from(command).command()) { + // TODO: Expand variables: #11164 + // + // let args = match variables::expand(...) { + // Ok(args: Cow<'_, str>) => args, + // Err(err) => { + // cx.editor.set_error(format!("{err}")); + // // Short circuit on error + // return; + // } + // } + // + // TEST: should allow for an option `%{arg}` even if no path is path is provided and work as if // the `%{arg}` eas never present. // @@ -3181,12 +3190,12 @@ pub(super) fn command_mode(cx: &mut Context) { // // If `false`, then will assume that that command was passed arguments in expansion and that // whats left is the full argument list to be sent run. - let args = if is_standalone(command) { - Shellwords::from(command).args() - } else { + let args = if contains_arg_variable(command) { // Input args // TODO: Args::from(&args) from the expanded variables. shellwords.args() + } else { + Shellwords::from(command).args() }; if let Err(err) = (typed_command.fun)(cx, args, event) { @@ -3194,6 +3203,58 @@ pub(super) fn command_mode(cx: &mut Context) { // Short circuit on error return; } + // Handle static commands + } else if let Some(static_command) = super::MappableCommand::STATIC_COMMAND_LIST + .iter() + .find(|mappable| mappable.name() == Shellwords::from(command).command()) + { + let mut cx = super::Context { + register: None, + count: None, + editor: cx.editor, + callback: vec![], + on_next_key_callback: None, + jobs: cx.jobs, + }; + + let MappableCommand::Static { fun, .. } = static_command else { + unreachable!("should only be able to get a static command from `STATIC_COMMAND_LIST`") + }; + + (fun)(&mut cx); + // Handle macro + } else if let Some(suffix) = command.strip_prefix('@') { + let keys = match helix_view::input::parse_macro(suffix) { + Ok(keys) => keys, + Err(err) => { + cx.editor + .set_error(format!("failed to parse macro `{command}`: {err}")); + return; + } + }; + + // Protect against recursive macros. + if cx.editor.macro_replaying.contains(&'@') { + cx.editor.set_error("Cannot execute macro because the [@] register is already playing a macro"); + return; + } + + let mut cx = super::Context { + register: None, + count: None, + editor: cx.editor, + callback: vec![], + on_next_key_callback: None, + jobs: cx.jobs, + }; + + cx.editor.macro_replaying.push('@'); + cx.callback.push(Box::new(move |compositor, cx| { + for key in keys { + compositor.handle_event(&compositor::Event::Key(key), cx); + } + cx.editor.macro_replaying.pop(); + })); } else if event == PromptEvent::Validate { cx.editor.set_error(format!("no such command: '{command}'")); // Short circuit on error @@ -3201,7 +3262,7 @@ pub(super) fn command_mode(cx: &mut Context) { } } } - // Handle typable commands + // Handle typable commands as normal else if let Some(cmd) = typed::TYPABLE_COMMAND_MAP.get(command) { if let Err(err) = (cmd.fun)(cx, shellwords.args(), event) { cx.editor.set_error(format!("{err}")); @@ -3245,9 +3306,11 @@ fn argument_number_of(shellwords: &Shellwords) -> usize { .saturating_sub(1 - usize::from(shellwords.ends_with_whitespace())) } -// NOTE: Only used in one spot +// TODO: Will turn into check for an input argument so that it cannot be `%{arg}` +// as this could cause issues with expansion recursion +// NOTE: Only used in one #[inline(always)] -fn is_standalone(command: &str) -> bool { +fn contains_arg_variable(command: &str) -> bool { let mut idx = 0; let bytes = command.as_bytes(); @@ -3268,7 +3331,7 @@ fn is_standalone(command: &str) -> bool { for byte in &bytes[idx..] { // Found end of arg bracket if *byte == b'}' && is_prev_digit { - return false; + return true; } if char::from(*byte).is_ascii_digit() { @@ -3279,7 +3342,7 @@ fn is_standalone(command: &str) -> bool { } } [b'a', b'r', b'g', b'}', ..] => { - return false; + return true; } _ => { idx += 2 + 4; @@ -3300,7 +3363,7 @@ fn is_standalone(command: &str) -> bool { continue; } - true + false } #[test] @@ -3322,20 +3385,20 @@ fn test_argument_number_of() { } #[test] -fn should_indicate_if_command_is_standalone() { - assert!(is_standalone("write --force")); +fn should_indicate_if_command_contained_arg_variable() { + assert!(!contains_arg_variable("write --force")); // Must provide digit - assert!(is_standalone("write --force %{arg:}")); + assert!(!contains_arg_variable("write --force %{arg:}")); // Muts have `:` before digits can be added - assert!(is_standalone("write --force %{arg122444}")); + assert!(!contains_arg_variable("write --force %{arg122444}")); // Must have closing bracket - assert!(is_standalone("write --force %{arg")); - assert!(is_standalone("write --force %{arg:1")); + assert!(!contains_arg_variable("write --force %{arg")); + assert!(!contains_arg_variable("write --force %{arg:1")); // Has valid variable - assert!(!is_standalone("write --force %{arg}")); - assert!(!is_standalone("write --force %{arg:1083472348978}")); + assert!(contains_arg_variable("write --force %{arg}")); + assert!(contains_arg_variable("write --force %{arg:1083472348978}")); } From 3e965a939982cfeaf3348998cf5d60439d3aca2d Mon Sep 17 00:00:00 2001 From: Rolo Date: Tue, 24 Dec 2024 22:06:31 -0800 Subject: [PATCH 10/16] wip: comment HACK for dealing with lifetimes --- helix-term/src/commands/typed.rs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/helix-term/src/commands/typed.rs b/helix-term/src/commands/typed.rs index d6374305d2a2..3fb2aff8923f 100644 --- a/helix-term/src/commands/typed.rs +++ b/helix-term/src/commands/typed.rs @@ -3060,7 +3060,6 @@ pub static TYPABLE_COMMAND_MAP: Lazy Date: Wed, 25 Dec 2024 04:40:25 -0800 Subject: [PATCH 11/16] doc: remove todos --- helix-view/src/commands/custom.rs | 123 +----------------------------- 1 file changed, 4 insertions(+), 119 deletions(-) diff --git a/helix-view/src/commands/custom.rs b/helix-view/src/commands/custom.rs index bbf047a154a4..36779b0fc119 100644 --- a/helix-view/src/commands/custom.rs +++ b/helix-view/src/commands/custom.rs @@ -1,44 +1,8 @@ -// If a combination takes arguments, say with a `%{arg}` syntax, it could be positional -// so that an Args could just run a simple `next` and replace as it encounters them. -// -// For example: -// "wcd!" = [":write --force %{arg}", ":cd %sh{ %{arg} | path dirname}"] -// -// Would this take arguments like: -// :wcd! /repo/sub/sub/file.txt -// $ :write --force repo/sub/sub/file -> cd /repo/sub/sub/ -// -// Also want to be able to support prompt usage, allowing the ability to add prompt options in the -// config: -// "wcd!" = { -// commands = [":write --force %{arg}", ":cd %sh{ %{arg} | path dirname}"], -// desc= "writes buffer forcefully, then changes to its directory" -// # path would be a hardcoded completion option. -// # Might also be able to name other commands to get there completions? -// # completions = "write" -// completions = "path" -// # allow custom list of completions -// completions = [ -// "foo", -// "bar", -// "baz", -// ] -// -// TODO: mention the handling of optionl and required arguments, and that it will just be forwarded to the command -// and any checking it has itself. -// [commands.wcd!] -// commands = [":write --force %{arg}", ":cd %sh{ %{arg} | path dirname }"] -// desc = "writes buffer forcefully, then changes to its directory" -// completions = "write" -// accepts = "" -// -// %{arg} and %{arg:0} are equivalent. -// These represent arguments passed down from the command call. -// As this will call `Args::nth(arg:NUM)`, you can just number them from 0.. -// to access the corresponding arg. -// // TODO: When adding custom aliases to the command prompt list, must priotize the custom over the built-in. -// Should include removing the alias from the aliases command? +// - Should include removing the alias from the aliases command? +// +// TODO: Need to get access to a new table in the config: [commands]. +// TODO: Could add an `aliases` to `CustomTypableCommand` and then add those as well? use serde::{Deserialize, Serialize}; @@ -107,82 +71,3 @@ impl CustomTypableCommand { } } -// TODO: Need to get access to a new table in the config: [commands]. -// TODO: If anycommands can be supported, then can add a check for `MappableCommand::STATIC_COMMAND_LIST`. -// Might also need to make a `MappableCommand::STATIC_COMMAND_MAP`. -// -// Could also just add support directly to `MappableCommand::from_str`. This would then allow a `command.execute` -// and support macros as well. - -// Checking against user provided commands first gives priority -// to user defined aliases over the built-in, allowing for overriding. -// if let Some(custom: &CustomTypeableCommand) = cx.editor.config.load().commands.get(name) { -// for command: &str in custom.commands.iter() { -// let shellwords = Shellwords::from(command); -// -// if let Some(command: &TypeableCommand) = typed::TYPABLE_COMMAND_MAP.get(shellwords.command()) { -// // TODO: Add parsing support for u%{arg:NUM}` in `expand`. -// let args = match variables::expand(cx.editor, shellwords.args().raw(), event == PromptEvent::Validate) { -// Ok(args) => args, -// Err(err) => { -// cx.editor.set_error(format!("{err}")); -// // short circuit if error -// return; -// }, -// } -// -// if let Err(err) = (command.fun)(cx, Args::from(&args), command.flags, event) { -// cx.editor.set_error(format!("{err}")); -// // short circuit if error -// return; -// } -// } else { -// cx.editor.set_error(format!("command `:{}` is not a valid command", shellwords.command())); -// // short circuit if error -// return; -// } -// } else if let Some(command) = typed::TYPABLE_COMMAND_MAP.get(shellwords.command()) { -// // Current impl -// } -// -// -// TODO: Could add an `aliases` to `CustomTypableCommand` and then add those as well? -// Prompt: -// -// fuzzy_match( -// input, -// // `chain` the names that are yielded buy the iterator. -// TYPABLE_COMMAND_LIST.iter().map(|command| command.name).chain(editor.config.load().commands.iter()), -// false, -// ) -// .into_iter() -// .map(|(name, _)| (0.., name.into())) -// .collect() -// -// -// Completer: -// -// let command = = editor -// .config -// .load() -// .commands -// .get(command) -// .map(|command | command.completer) -// .unwrap_or(command); -// -// TYPABLE_COMMAND_MAP -// .get(command) -// .map(|tc| tc.completer_for_argument_number(argument_number_of(&shellwords))) -// .map_or_else(Vec::new, |completer| { -// completer(editor, word) -// .into_iter() -// .map(|(range, mut file)| { -// file.content = shellwords::escape(file.content); -// -// // offset ranges to input -// let offset = input.len() - len; -// let range = (range.start + offset)..; -// (range, file) -// }) -// .collect() -// }) From c29331d85b63fe017a9416bb4f3a8b42c42e319b Mon Sep 17 00:00:00 2001 From: Rolo Date: Wed, 25 Dec 2024 05:03:01 -0800 Subject: [PATCH 12/16] feat: add support for `^` prefix escaping --- helix-term/src/commands/typed.rs | 264 ++++++++++++++++-------------- helix-view/src/commands/custom.rs | 3 +- 2 files changed, 145 insertions(+), 122 deletions(-) diff --git a/helix-term/src/commands/typed.rs b/helix-term/src/commands/typed.rs index 3fb2aff8923f..1797647f35fe 100644 --- a/helix-term/src/commands/typed.rs +++ b/helix-term/src/commands/typed.rs @@ -3066,25 +3066,33 @@ pub(super) fn command_mode(cx: &mut Context) { let mut prompt = Prompt::new( ":".into(), Some(':'), + // completion move |editor: &Editor, input: &str| { - let shellwords = Shellwords::from(input); + let shellwords = Shellwords::from(input.trim_start_matches('^')); let command = shellwords.command(); let commands = commands.clone(); let names = commands.names(); // HACK: Cloning(to_string) because of lifetimes - let items = TYPABLE_COMMAND_LIST - .iter() - .map(|command| command.name) - .chain(names) - .map(|name| name.to_string()) - .collect::>(); + let items = if input.starts_with('^') { + TYPABLE_COMMAND_LIST + .iter() + .map(|command| command.name.to_string()) + .collect::>() + } else { + TYPABLE_COMMAND_LIST + .iter() + .map(|command| command.name) + .chain(names) + .map(|name| name.to_string()) + .collect::>() + }; if command.is_empty() || (shellwords.args().next().is_none() && !shellwords.ends_with_whitespace()) { - fuzzy_match(input, items, false) + fuzzy_match(command, items, false) .into_iter() .map(|(name, _)| (0.., name.into())) .collect() @@ -3120,7 +3128,8 @@ pub(super) fn command_mode(cx: &mut Context) { .collect() }) } - }, // completion + }, + // callback move |cx: &mut compositor::Context, input: &str, event: PromptEvent| { let shellwords = Shellwords::from(input); let command = shellwords.command(); @@ -3145,119 +3154,136 @@ pub(super) fn command_mode(cx: &mut Context) { ); } - // Checking for custom commands first priotizes custom commands over built-in/ - if let Some(custom) = cx.editor.config().commands.get(command) { - for command in custom.iter() { - if let Some(typed_command) = - typed::TYPABLE_COMMAND_MAP.get(Shellwords::from(command).command()) - { - // TODO: Expand variables: #11164 - // - // let args = match variables::expand(...) { - // Ok(args: Cow<'_, str>) => args, - // Err(err) => { - // cx.editor.set_error(format!("{err}")); - // // Short circuit on error - // return; - // } - // } - // - - // TEST: should allow for an option `%{arg}` even if no path is path is provided and work as if - // the `%{arg}` eas never present. - // - // Assume that if the command contains an `%{arg[:NUMBER]}` it will be accepting arguments from - // input and therefore not standalone. - // - // If `false`, then will use any arguments the command itself may have been written - // with and ignore any typed-in arguments. - // - // This is a special case for when config has simplest usage: - // - // "ww" = ":write --force" - // - // It will only use: `--force` as arguments when running `:write`. - // - // This also means that users dont have to explicitly use `%{arg}` every time: - // - // "ww" = ":write --force %{arg}" - // - // Though in the case of `:write`, they probably should. - // - // Regardless, some commands explicitly take zero arguments and this check should prevent - // input arguments being passed when they shouldnt. - // - // If `true`, then will assume that the command was passed arguments in expansion and that - // whats left is the full argument list to be sent run. - let args = if contains_arg_variable(command) { - // Input args - // TODO: Args::from(&args) from the expanded variables. - shellwords.args() - } else { - Shellwords::from(command).args() - }; - - if let Err(err) = (typed_command.fun)(cx, args, event) { - cx.editor.set_error(format!("{err}")); - // Short circuit on error - return; - } - // Handle static commands - } else if let Some(static_command) = super::MappableCommand::STATIC_COMMAND_LIST - .iter() - .find(|mappable| mappable.name() == Shellwords::from(command).command()) - { - let mut cx = super::Context { - register: None, - count: None, - editor: cx.editor, - callback: vec![], - on_next_key_callback: None, - jobs: cx.jobs, - }; - - let MappableCommand::Static { fun, .. } = static_command else { - unreachable!("should only be able to get a static command from `STATIC_COMMAND_LIST`") - }; - - (fun)(&mut cx); - // Handle macro - } else if let Some(suffix) = command.strip_prefix('@') { - let keys = match helix_view::input::parse_macro(suffix) { - Ok(keys) => keys, - Err(err) => { - cx.editor - .set_error(format!("failed to parse macro `{command}`: {err}")); + let is_escaped = command.starts_with('^'); + let command = command.trim_start_matches('^'); + + // Checking for custom commands first priotizes custom commands over built-in. + // + // Custom commands can be escaped with a `^`. + // + // TODO: When let chains are stable reduce nestedness + if !is_escaped { + if let Some(custom) = cx.editor.config().commands.get(command) { + for command in custom.iter() { + if let Some(typed_command) = + typed::TYPABLE_COMMAND_MAP.get(Shellwords::from(command).command()) + { + // TODO: Expand variables: #11164 + // + // let args = match variables::expand(...) { + // Ok(args: Cow<'_, str>) => args, + // Err(err) => { + // cx.editor.set_error(format!("{err}")); + // // Short circuit on error + // return; + // } + // } + // + + // TEST: should allow for an option `%{arg}` even if no path is path is provided and work as if + // the `%{arg}` eas never present. + // + // Assume that if the command contains an `%{arg[:NUMBER]}` it will be accepting arguments from + // input and therefore not standalone. + // + // If `false`, then will use any arguments the command itself may have been written + // with and ignore any typed-in arguments. + // + // This is a special case for when config has simplest usage: + // + // "ww" = ":write --force" + // + // It will only use: `--force` as arguments when running `:write`. + // + // This also means that users dont have to explicitly use `%{arg}` every time: + // + // "ww" = ":write --force %{arg}" + // + // Though in the case of `:write`, they probably should. + // + // Regardless, some commands explicitly take zero arguments and this check should prevent + // input arguments being passed when they shouldnt. + // + // If `true`, then will assume that the command was passed arguments in expansion and that + // whats left is the full argument list to be sent run. + let args = if contains_arg_variable(command) { + // Input args + // TODO: Args::from(&args) from the expanded variables. + shellwords.args() + } else { + Shellwords::from(command).args() + }; + + if let Err(err) = (typed_command.fun)(cx, args, event) { + cx.editor.set_error(format!("{err}")); + // Short circuit on error return; } - }; + // Handle static commands + } else if let Some(static_command) = + super::MappableCommand::STATIC_COMMAND_LIST + .iter() + .find(|mappable| { + mappable.name() == Shellwords::from(command).command() + }) + { + let mut cx = super::Context { + register: None, + count: None, + editor: cx.editor, + callback: vec![], + on_next_key_callback: None, + jobs: cx.jobs, + }; + + let MappableCommand::Static { fun, .. } = static_command else { + unreachable!("should only be able to get a static command from `STATIC_COMMAND_LIST`") + }; + + (fun)(&mut cx); + // Handle macro + } else if let Some(suffix) = command.strip_prefix('@') { + let keys = match helix_view::input::parse_macro(suffix) { + Ok(keys) => keys, + Err(err) => { + cx.editor.set_error(format!( + "failed to parse macro `{command}`: {err}" + )); + return; + } + }; - // Protect against recursive macros. - if cx.editor.macro_replaying.contains(&'@') { - cx.editor.set_error("Cannot execute macro because the [@] register is already playing a macro"); + // Protect against recursive macros. + if cx.editor.macro_replaying.contains(&'@') { + cx.editor.set_error("Cannot execute macro because the [@] register is already playing a macro"); + return; + } + + let mut cx = super::Context { + register: None, + count: None, + editor: cx.editor, + callback: vec![], + on_next_key_callback: None, + jobs: cx.jobs, + }; + + cx.editor.macro_replaying.push('@'); + cx.callback.push(Box::new(move |compositor, cx| { + for key in keys { + compositor.handle_event(&compositor::Event::Key(key), cx); + } + cx.editor.macro_replaying.pop(); + })); + } else if event == PromptEvent::Validate { + cx.editor.set_error(format!("no such command: '{command}'")); + // Short circuit on error return; } - - let mut cx = super::Context { - register: None, - count: None, - editor: cx.editor, - callback: vec![], - on_next_key_callback: None, - jobs: cx.jobs, - }; - - cx.editor.macro_replaying.push('@'); - cx.callback.push(Box::new(move |compositor, cx| { - for key in keys { - compositor.handle_event(&compositor::Event::Key(key), cx); - } - cx.editor.macro_replaying.pop(); - })); - } else if event == PromptEvent::Validate { - cx.editor.set_error(format!("no such command: '{command}'")); - // Short circuit on error - return; + } + } else if let Some(cmd) = typed::TYPABLE_COMMAND_MAP.get(command) { + if let Err(err) = (cmd.fun)(cx, shellwords.args(), event) { + cx.editor.set_error(format!("{err}")); } } } @@ -3277,9 +3303,7 @@ pub(super) fn command_mode(cx: &mut Context) { let shellwords = Shellwords::from(input); if let Some(command) = commands.clone().get(input) { - if let Some(desc) = &command.desc { - return Some(desc.clone().into()); - } + return Some(command.prompt().into()); } else if let Some(typed::TypableCommand { doc, aliases, .. }) = typed::TYPABLE_COMMAND_MAP.get(shellwords.command()) { diff --git a/helix-view/src/commands/custom.rs b/helix-view/src/commands/custom.rs index 36779b0fc119..a99189622728 100644 --- a/helix-view/src/commands/custom.rs +++ b/helix-view/src/commands/custom.rs @@ -1,6 +1,6 @@ // TODO: When adding custom aliases to the command prompt list, must priotize the custom over the built-in. // - Should include removing the alias from the aliases command? -// +// // TODO: Need to get access to a new table in the config: [commands]. // TODO: Could add an `aliases` to `CustomTypableCommand` and then add those as well? @@ -70,4 +70,3 @@ impl CustomTypableCommand { .map(|command| command.trim_start_matches(':')) } } - From ea543094b478d1c4f1d6e9fa00c767e921841202 Mon Sep 17 00:00:00 2001 From: Rolo Date: Wed, 25 Dec 2024 08:17:18 -0800 Subject: [PATCH 13/16] feat: build out prompt structure --- helix-view/src/commands/custom.rs | 87 +++++++++++++++++++++++++++---- 1 file changed, 77 insertions(+), 10 deletions(-) diff --git a/helix-view/src/commands/custom.rs b/helix-view/src/commands/custom.rs index a99189622728..b9069b8b0b99 100644 --- a/helix-view/src/commands/custom.rs +++ b/helix-view/src/commands/custom.rs @@ -4,9 +4,12 @@ // TODO: Need to get access to a new table in the config: [commands]. // TODO: Could add an `aliases` to `CustomTypableCommand` and then add those as well? +use std::fmt::Write; + use serde::{Deserialize, Serialize}; // TODO: Might need to manually implement Serialize and Deserialize +// -Will need to do so if want to use `Arc` to make cloning cheaper. #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] pub struct CustomTypeableCommands { pub commands: Vec, @@ -15,15 +18,32 @@ pub struct CustomTypeableCommands { impl Default for CustomTypeableCommands { fn default() -> Self { Self { - commands: vec![CustomTypableCommand { - name: String::from(":lg"), - desc: Some(String::from("runs lazygit in a floating pane")), - commands: vec![String::from( - ":sh wezterm cli spawn --floating-pane lazygit", - )], - accepts: None, - completer: None, - }], + commands: vec![ + CustomTypableCommand { + name: String::from(":lg"), + desc: Some(String::from("runs lazygit in a floating pane")), + commands: vec![String::from( + ":sh wezterm cli spawn --floating-pane lazygit", + )], + accepts: None, + completer: None, + }, + CustomTypableCommand { + name: String::from(":w"), + desc: Some(String::from( + "writes buffer forcefully and changes directory", + )), + commands: vec![ + String::from(":write --force %{arg}"), + String::from(":cd %sh{ %{arg} | path dirname }"), + String::from(":cd %sh{ %{arg} | path dirname }"), + String::from(":cd %sh{ %{arg} | path dirname }"), + String::from(":cd %sh{ %{arg} | path dirname }"), + ], + accepts: Some(String::from("")), + completer: Some(String::from(":write")), + }, + ], } } } @@ -46,6 +66,7 @@ impl CustomTypeableCommands { } } +// TODO: Arc ? #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] pub struct CustomTypableCommand { pub name: String, @@ -61,7 +82,53 @@ impl CustomTypableCommand { // // maps: // :write --force %{arg} -> :cd %sh{ %{arg} | path dirname } - todo!() + let mut prompt = String::new(); + + prompt.push_str(self.name.trim_start_matches(':')); + + if let Some(accepts) = &self.accepts { + write!(prompt, " {accepts}").unwrap(); + } + + prompt.push(':'); + + // TODO: Might need to port the spacing algo from argument flags branch. + if let Some(desc) = &self.desc { + write!(prompt, " {desc}").unwrap(); + } + + prompt.push('\n'); + prompt.push('\n'); + + writeln!(prompt, "maps:").unwrap(); + prompt.push_str(" "); + + for (idx, command) in self.commands.iter().enumerate() { + write!(prompt, ":{}", command.trim_start_matches(':')).unwrap(); + + if idx + 1 == self.commands.len() { + break; + } + + // There are two columns of commands, and after that they will overflow + // downward: + // + // maps: + // :write --force %{arg} -> :cd %sh{ %{arg} | path dirname } + // -> :write --force %{arg} -> :cd %sh{ %{arg} | path dirname } + // -> :write --force %{arg} -> :cd %sh{ %{arg} | path dirname } + // + // Its starts with `->` to indicate that its not a new `:command` + // but still one sequence. + if idx % 2 == 0 { + prompt.push('\n'); + prompt.push_str(" -> "); + } else { + prompt.push_str(" -> "); + } + } + + prompt } pub fn iter(&self) -> impl Iterator { From 29143bfa9a41c7e4779e3ef74dadf03910b9d17b Mon Sep 17 00:00:00 2001 From: Rolo Date: Wed, 25 Dec 2024 22:39:57 -0800 Subject: [PATCH 14/16] refactor: make `CustomTypableCommands` cheaper to clone --- helix-term/src/commands/typed.rs | 36 +++++++------------ helix-view/src/commands/custom.rs | 60 ++++++++++++++----------------- helix-view/src/editor.rs | 1 + 3 files changed, 41 insertions(+), 56 deletions(-) diff --git a/helix-term/src/commands/typed.rs b/helix-term/src/commands/typed.rs index 1797647f35fe..39adbd434e1e 100644 --- a/helix-term/src/commands/typed.rs +++ b/helix-term/src/commands/typed.rs @@ -1,7 +1,6 @@ use std::fmt::Write; use std::io::BufReader; use std::ops::Deref; -use std::sync::Arc; use crate::job::Job; @@ -3060,9 +3059,7 @@ pub static TYPABLE_COMMAND_MAP: Lazy>() - } else { - TYPABLE_COMMAND_LIST - .iter() - .map(|command| command.name) - .chain(names) - .map(|name| name.to_string()) - .collect::>() - }; + let items = TYPABLE_COMMAND_LIST + .iter() + .map(|command| command.name) + .chain(commands.names()) + // HACK: `to_string` because of lifetimes: + // + // captured variable cannot escape `FnMut` closure body + // `FnMut` closures only have access to their captured variables while they are executing + // therefore, they cannot allow references to captured variables to escape + .map(|name| name.to_string()); if command.is_empty() || (shellwords.args().next().is_none() && !shellwords.ends_with_whitespace()) @@ -3298,11 +3288,11 @@ pub(super) fn command_mode(cx: &mut Context) { }, ); - let commands = arced.clone(); + let commands = cx.editor.config().commands.clone(); prompt.doc_fn = Box::new(move |input: &str| { let shellwords = Shellwords::from(input); - if let Some(command) = commands.clone().get(input) { + if let Some(command) = commands.get(input) { return Some(command.prompt().into()); } else if let Some(typed::TypableCommand { doc, aliases, .. }) = typed::TYPABLE_COMMAND_MAP.get(shellwords.command()) diff --git a/helix-view/src/commands/custom.rs b/helix-view/src/commands/custom.rs index b9069b8b0b99..5cfa4f1e743e 100644 --- a/helix-view/src/commands/custom.rs +++ b/helix-view/src/commands/custom.rs @@ -4,15 +4,11 @@ // TODO: Need to get access to a new table in the config: [commands]. // TODO: Could add an `aliases` to `CustomTypableCommand` and then add those as well? -use std::fmt::Write; +use std::{fmt::Write, sync::Arc}; -use serde::{Deserialize, Serialize}; - -// TODO: Might need to manually implement Serialize and Deserialize -// -Will need to do so if want to use `Arc` to make cloning cheaper. -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct CustomTypeableCommands { - pub commands: Vec, + pub commands: Arc<[CustomTypableCommand]>, } impl Default for CustomTypeableCommands { @@ -20,30 +16,29 @@ impl Default for CustomTypeableCommands { Self { commands: vec![ CustomTypableCommand { - name: String::from(":lg"), - desc: Some(String::from("runs lazygit in a floating pane")), - commands: vec![String::from( - ":sh wezterm cli spawn --floating-pane lazygit", - )], + name: Arc::from(":lg"), + desc: Some(Arc::from("runs lazygit in a floating pane")), + commands: vec![Arc::from(":sh wezterm cli spawn --floating-pane lazygit")] + .into(), accepts: None, completer: None, }, CustomTypableCommand { - name: String::from(":w"), - desc: Some(String::from( - "writes buffer forcefully and changes directory", - )), + name: Arc::from(":w"), + desc: Some(Arc::from("writes buffer forcefully and changes directory")), commands: vec![ - String::from(":write --force %{arg}"), - String::from(":cd %sh{ %{arg} | path dirname }"), - String::from(":cd %sh{ %{arg} | path dirname }"), - String::from(":cd %sh{ %{arg} | path dirname }"), - String::from(":cd %sh{ %{arg} | path dirname }"), - ], - accepts: Some(String::from("")), - completer: Some(String::from(":write")), + Arc::from(":write --force %{arg}"), + Arc::from(":cd %sh{ %{arg} | path dirname }"), + Arc::from(":cd %sh{ %{arg} | path dirname }"), + Arc::from(":cd %sh{ %{arg} | path dirname }"), + Arc::from(":cd %sh{ %{arg} | path dirname }"), + ] + .into(), + accepts: Some(Arc::from("")), + completer: Some(Arc::from(":write")), }, - ], + ] + .into(), } } } @@ -62,18 +57,17 @@ impl CustomTypeableCommands { self.commands .iter() // ":wbc!" -> "wbc!" - .map(|command| command.name.as_str().trim_start_matches(':')) + .map(|command| command.name.as_ref()) } } -// TODO: Arc ? -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct CustomTypableCommand { - pub name: String, - pub desc: Option, - pub commands: Vec, - pub accepts: Option, - pub completer: Option, + pub name: Arc, + pub desc: Option>, + pub commands: Arc<[Arc]>, + pub accepts: Option>, + pub completer: Option>, } impl CustomTypableCommand { diff --git a/helix-view/src/editor.rs b/helix-view/src/editor.rs index 2e648302c281..776ef90f7454 100644 --- a/helix-view/src/editor.rs +++ b/helix-view/src/editor.rs @@ -361,6 +361,7 @@ pub struct Config { pub end_of_line_diagnostics: DiagnosticFilter, // Set to override the default clipboard provider pub clipboard_provider: ClipboardProvider, + #[serde(skip)] /// Custom typeable commands pub commands: CustomTypeableCommands, } From 2e03ce52e9dbad3ae211ab8060be6e8a96b6514a Mon Sep 17 00:00:00 2001 From: Rolo Date: Thu, 26 Dec 2024 00:06:51 -0800 Subject: [PATCH 15/16] feat: add `[commands]` table to config parsing --- helix-term/src/config.rs | 131 +++++++++++++++++++++++++++--- helix-view/src/commands/custom.rs | 47 ++--------- 2 files changed, 127 insertions(+), 51 deletions(-) diff --git a/helix-term/src/config.rs b/helix-term/src/config.rs index bcba8d8e1d45..7ebf37e70388 100644 --- a/helix-term/src/config.rs +++ b/helix-term/src/config.rs @@ -1,12 +1,14 @@ use crate::keymap; use crate::keymap::{merge_keys, KeyTrie}; use helix_loader::merge_toml_values; +use helix_view::commands::custom::CustomTypableCommand; use helix_view::document::Mode; use serde::Deserialize; use std::collections::HashMap; use std::fmt::Display; use std::fs; use std::io::Error as IOError; +use std::sync::Arc; use toml::de::Error as TomlError; #[derive(Debug, Clone, PartialEq)] @@ -22,6 +24,7 @@ pub struct ConfigRaw { pub theme: Option, pub keys: Option>, pub editor: Option, + commands: Option, } impl Default for Config { @@ -65,7 +68,7 @@ impl Config { let local_config: Result = local.and_then(|file| toml::from_str(&file).map_err(ConfigLoadError::BadConfig)); let res = match (global_config, local_config) { - (Ok(global), Ok(local)) => { + (Ok(mut global), Ok(local)) => { let mut keys = keymap::default(); if let Some(global_keys) = global.keys { merge_keys(&mut keys, global_keys) @@ -74,7 +77,7 @@ impl Config { merge_keys(&mut keys, local_keys) } - let editor = match (global.editor, local.editor) { + let mut editor = match (global.editor, local.editor) { (None, None) => helix_view::editor::Config::default(), (None, Some(val)) | (Some(val), None) => { val.try_into().map_err(ConfigLoadError::BadConfig)? @@ -84,6 +87,26 @@ impl Config { .map_err(ConfigLoadError::BadConfig)?, }; + // Merge locally defined commands, overwriting global space commands if encountered + if let Some(lcommands) = local.commands { + if let Some(gcommands) = &mut global.commands { + for (name, details) in lcommands.commands { + gcommands.commands.insert(name, details); + } + } else { + global.commands = Some(lcommands); + } + } + + // If any commands were defined anywhere, add to editor + if let Some(commands) = global.commands { + editor.commands.commands = commands + .commands + .into_iter() + .map(|(name, details)| details.into_custom_command(name)) + .collect(); + } + Config { theme: local.theme.or(global.theme), keys, @@ -100,13 +123,25 @@ impl Config { if let Some(keymap) = config.keys { merge_keys(&mut keys, keymap); } + + let mut editor = config.editor.map_or_else( + || Ok(helix_view::editor::Config::default()), + |val| val.try_into().map_err(ConfigLoadError::BadConfig), + )?; + + // Add custom commands + if let Some(commands) = config.commands { + editor.commands.commands = commands + .commands + .into_iter() + .map(|(name, details)| details.into_custom_command(name)) + .collect(); + } + Config { theme: config.theme, keys, - editor: config.editor.map_or_else( - || Ok(helix_view::editor::Config::default()), - |val| val.try_into().map_err(ConfigLoadError::BadConfig), - )?, + editor, } } @@ -126,13 +161,75 @@ impl Config { } } +#[derive(Debug, Deserialize, PartialEq, Eq, Clone)] +struct Commands { + #[serde(flatten)] + commands: HashMap, +} + +#[derive(Debug, Deserialize, PartialEq, Eq, Clone)] +#[serde(untagged)] +enum CommandTomlType { + Single(String), + Multiple(Vec), + Detailed { + commands: Vec, + desc: Option, + accepts: Option, + completer: Option, + }, +} + +impl CommandTomlType { + fn into_custom_command(self, name: String) -> CustomTypableCommand { + let name = name.trim_start_matches(':'); + match self { + Self::Single(command) => CustomTypableCommand { + name: Arc::from(name), + desc: None, + commands: vec![Arc::from(command.trim_start_matches(':'))].into(), + accepts: None, + completer: None, + }, + Self::Multiple(commands) => CustomTypableCommand { + name: Arc::from(name), + desc: None, + commands: commands + .into_iter() + .map(|command| Arc::from(command.trim_start_matches(':'))) + .collect::>() + .into(), + accepts: None, + completer: None, + }, + Self::Detailed { + commands, + desc, + accepts, + completer, + } => CustomTypableCommand { + name: Arc::from(name), + desc: desc.map(Arc::from), + commands: commands + .into_iter() + .map(|command| Arc::from(command.trim_start_matches(':'))) + .collect::>() + .into(), + accepts: accepts.map(|accepts| Arc::from(accepts.trim_start_matches(':'))), + completer: completer.map(|completer| Arc::from(completer.trim_start_matches(':'))), + }, + } + } +} + #[cfg(test)] mod tests { + use super::*; impl Config { - fn load_test(config: &str) -> Config { - Config::load(Ok(config.to_owned()), Err(ConfigLoadError::default())).unwrap() + fn load_test(config: &str) -> Result { + Config::load(Ok(config.to_owned()), Err(ConfigLoadError::default())) } } @@ -166,7 +263,7 @@ mod tests { ); assert_eq!( - Config::load_test(sample_keymaps), + Config::load_test(sample_keymaps).unwrap(), Config { keys, ..Default::default() @@ -177,11 +274,25 @@ mod tests { #[test] fn keys_resolve_to_correct_defaults() { // From serde default - let default_keys = Config::load_test("").keys; + let default_keys = Config::load_test("").unwrap().keys; assert_eq!(default_keys, keymap::default()); // From the Default trait let default_keys = Config::default().keys; assert_eq!(default_keys, keymap::default()); } + + #[test] + fn should_deserialize_commands() { + let config = r#" +[commands] +":wq" = [":write", "quit"] +":w" = ":write --force" +":wcd!" = { commands = [':write --force %{arg}', ':cd %sh{ %{arg} | path dirname }'], desc = "writes buffer to disk forcefully, then changes to its directory", accepts = "", completer = ":write" } +"#; + + if let Err(err) = Config::load_test(config) { + panic!("{err:#?}") + }; + } } diff --git a/helix-view/src/commands/custom.rs b/helix-view/src/commands/custom.rs index 5cfa4f1e743e..954126264a5e 100644 --- a/helix-view/src/commands/custom.rs +++ b/helix-view/src/commands/custom.rs @@ -1,9 +1,3 @@ -// TODO: When adding custom aliases to the command prompt list, must priotize the custom over the built-in. -// - Should include removing the alias from the aliases command? -// -// TODO: Need to get access to a new table in the config: [commands]. -// TODO: Could add an `aliases` to `CustomTypableCommand` and then add those as well? - use std::{fmt::Write, sync::Arc}; #[derive(Debug, Clone, PartialEq, Eq)] @@ -14,31 +8,7 @@ pub struct CustomTypeableCommands { impl Default for CustomTypeableCommands { fn default() -> Self { Self { - commands: vec![ - CustomTypableCommand { - name: Arc::from(":lg"), - desc: Some(Arc::from("runs lazygit in a floating pane")), - commands: vec![Arc::from(":sh wezterm cli spawn --floating-pane lazygit")] - .into(), - accepts: None, - completer: None, - }, - CustomTypableCommand { - name: Arc::from(":w"), - desc: Some(Arc::from("writes buffer forcefully and changes directory")), - commands: vec![ - Arc::from(":write --force %{arg}"), - Arc::from(":cd %sh{ %{arg} | path dirname }"), - Arc::from(":cd %sh{ %{arg} | path dirname }"), - Arc::from(":cd %sh{ %{arg} | path dirname }"), - Arc::from(":cd %sh{ %{arg} | path dirname }"), - ] - .into(), - accepts: Some(Arc::from("")), - completer: Some(Arc::from(":write")), - }, - ] - .into(), + commands: Arc::new([]), } } } @@ -49,15 +19,12 @@ impl CustomTypeableCommands { pub fn get(&self, name: &str) -> Option<&CustomTypableCommand> { self.commands .iter() - .find(|command| command.name.trim_start_matches(':') == name.trim_start_matches(':')) + .find(|command| command.name.as_ref() == name) } #[inline] pub fn names(&self) -> impl Iterator { - self.commands - .iter() - // ":wbc!" -> "wbc!" - .map(|command| command.name.as_ref()) + self.commands.iter().map(|command| command.name.as_ref()) } } @@ -78,7 +45,7 @@ impl CustomTypableCommand { // :write --force %{arg} -> :cd %sh{ %{arg} | path dirname } let mut prompt = String::new(); - prompt.push_str(self.name.trim_start_matches(':')); + prompt.push_str(self.name.as_ref()); if let Some(accepts) = &self.accepts { write!(prompt, " {accepts}").unwrap(); @@ -98,7 +65,7 @@ impl CustomTypableCommand { prompt.push_str(" "); for (idx, command) in self.commands.iter().enumerate() { - write!(prompt, ":{}", command.trim_start_matches(':')).unwrap(); + write!(prompt, ":{command}").unwrap(); if idx + 1 == self.commands.len() { break; @@ -126,8 +93,6 @@ impl CustomTypableCommand { } pub fn iter(&self) -> impl Iterator { - self.commands - .iter() - .map(|command| command.trim_start_matches(':')) + self.commands.iter().map(|command| command.as_ref()) } } From a6b6a4053afd533e3c8e109f7016cfaa251a6560 Mon Sep 17 00:00:00 2001 From: Rolo Date: Thu, 26 Dec 2024 01:12:48 -0800 Subject: [PATCH 16/16] chore: rename test add placeholder test --- helix-term/src/config.rs | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/helix-term/src/config.rs b/helix-term/src/config.rs index 7ebf37e70388..b5e2d877eabc 100644 --- a/helix-term/src/config.rs +++ b/helix-term/src/config.rs @@ -283,7 +283,7 @@ mod tests { } #[test] - fn should_deserialize_commands() { + fn should_deserialize_custom_commands() { let config = r#" [commands] ":wq" = [":write", "quit"] @@ -295,4 +295,20 @@ mod tests { panic!("{err:#?}") }; } + + // TODO: See if this capabiliy till be allowed + // #[test] + // fn should_deserialize_custom_commands_into_keys() { + // let config = r#" + // [keys.normal.space] + // g = ":lg" + + // [commands] + // ":lg" = "" + // "#; + + // if let Err(err) = Config::load_test(config) { + // panic!("{err:#?}") + // }; + // } }