diff --git a/helix-term/src/commands/typed.rs b/helix-term/src/commands/typed.rs index 27e2c75d71f8..39adbd434e1e 100644 --- a/helix-term/src/commands/typed.rs +++ b/helix-term/src/commands/typed.rs @@ -3057,26 +3057,35 @@ pub static TYPABLE_COMMAND_MAP: Lazy) => 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"); + 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}")); + } + } + } + // 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}")); } @@ -3130,15 +3288,19 @@ pub(super) fn command_mode(cx: &mut Context) { }, ); - prompt.doc_fn = Box::new(|input: &str| { + let commands = cx.editor.config().commands.clone(); + prompt.doc_fn = Box::new(move |input: &str| { let shellwords = Shellwords::from(input); - if let Some(typed::TypableCommand { doc, aliases, .. }) = + 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()) { if aliases.is_empty() { return Some((*doc).into()); } + return Some(format!("{}\nAliases: {}", doc, aliases.join(", ")).into()); } @@ -3157,6 +3319,66 @@ fn argument_number_of(shellwords: &Shellwords) -> usize { .saturating_sub(1 - usize::from(shellwords.ends_with_whitespace())) } +// 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 contains_arg_variable(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 true; + } + + if char::from(*byte).is_ascii_digit() { + is_prev_digit = true; + } else { + break; + } + } + } + [b'a', b'r', b'g', b'}', ..] => { + return true; + } + _ => { + idx += 2 + 4; + continue; + } + } + } + idx += 1; + continue; + } + idx += 1; + continue; + } + idx += 1; + continue; + } + idx += 1; + continue; + } + + false +} + #[test] fn test_argument_number_of() { let cases = vec![ @@ -3174,3 +3396,22 @@ fn test_argument_number_of() { assert_eq!(case.1, argument_number_of(&Shellwords::from(case.0))); } } + +#[test] +fn should_indicate_if_command_contained_arg_variable() { + assert!(!contains_arg_variable("write --force")); + + // Must provide digit + assert!(!contains_arg_variable("write --force %{arg:}")); + + // Muts have `:` before digits can be added + assert!(!contains_arg_variable("write --force %{arg122444}")); + + // Must have closing bracket + assert!(!contains_arg_variable("write --force %{arg")); + assert!(!contains_arg_variable("write --force %{arg:1")); + + // Has valid variable + assert!(contains_arg_variable("write --force %{arg}")); + assert!(contains_arg_variable("write --force %{arg:1083472348978}")); +} diff --git a/helix-term/src/config.rs b/helix-term/src/config.rs index bcba8d8e1d45..b5e2d877eabc 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,41 @@ 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_custom_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:#?}") + }; + } + + // 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:#?}") + // }; + // } } diff --git a/helix-view/src/commands/custom.rs b/helix-view/src/commands/custom.rs new file mode 100644 index 000000000000..954126264a5e --- /dev/null +++ b/helix-view/src/commands/custom.rs @@ -0,0 +1,98 @@ +use std::{fmt::Write, sync::Arc}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CustomTypeableCommands { + pub commands: Arc<[CustomTypableCommand]>, +} + +impl Default for CustomTypeableCommands { + fn default() -> Self { + Self { + commands: Arc::new([]), + } + } +} + +impl CustomTypeableCommands { + #[inline] + #[must_use] + pub fn get(&self, name: &str) -> Option<&CustomTypableCommand> { + self.commands + .iter() + .find(|command| command.name.as_ref() == name) + } + + #[inline] + pub fn names(&self) -> impl Iterator { + self.commands.iter().map(|command| command.name.as_ref()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CustomTypableCommand { + pub name: Arc, + pub desc: Option>, + pub commands: Arc<[Arc]>, + pub accepts: Option>, + pub completer: Option>, +} + +impl CustomTypableCommand { + pub fn prompt(&self) -> String { + // wcd! : writes buffer forcefully, then changes to its directory + // + // maps: + // :write --force %{arg} -> :cd %sh{ %{arg} | path dirname } + let mut prompt = String::new(); + + prompt.push_str(self.name.as_ref()); + + 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}").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 { + self.commands.iter().map(|command| command.as_ref()) + } +} 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/editor.rs b/helix-view/src/editor.rs index 6c585a8a7f2c..776ef90f7454 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::CustomTypeableCommands, document::{ DocumentOpenError, DocumentSavedEventFuture, DocumentSavedEventResult, Mode, SavePoint, }, @@ -360,6 +361,9 @@ 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, } #[derive(Debug, Clone, PartialEq, Deserialize, Serialize, Eq, PartialOrd, Ord)] @@ -1001,6 +1005,7 @@ impl Default for Config { inline_diagnostics: InlineDiagnosticsConfig::default(), end_of_line_diagnostics: DiagnosticFilter::Disable, clipboard_provider: ClipboardProvider::default(), + commands: CustomTypeableCommands::default(), } } } 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;