Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(commands): add support for custom typable commands #12320

Draft
wants to merge 16 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
273 changes: 257 additions & 16 deletions helix-term/src/commands/typed.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3057,26 +3057,35 @@ pub static TYPABLE_COMMAND_MAP: Lazy<HashMap<&'static str, &'static TypableComma
.collect()
});

#[allow(clippy::unnecessary_unwrap)]
#[allow(clippy::unnecessary_unwrap, clippy::too_many_lines)]
pub(super) fn command_mode(cx: &mut Context) {
let commands = cx.editor.config().commands.clone();
let mut prompt = Prompt::new(
":".into(),
Some(':'),
|editor: &Editor, input: &str| {
let shellwords = Shellwords::from(input);
// completion
move |editor: &Editor, input: &str| {
let shellwords = Shellwords::from(input.trim_start_matches('^'));
let command = shellwords.command();

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())
{
fuzzy_match(
input,
TYPABLE_COMMAND_LIST.iter().map(|command| command.name),
false,
)
.into_iter()
.map(|(name, _)| (0.., name.into()))
.collect()
fuzzy_match(command, items, false)
.into_iter()
.map(|(name, _)| (0.., name.into()))
.collect()
} else {
// Otherwise, use the command's completer and the last shellword
// as completion input.
Expand All @@ -3085,6 +3094,13 @@ pub(super) fn command_mode(cx: &mut Context) {
.last()
.map_or(("", 0), |last| (last, last.len()));

// TODO: Need to validate that the command name given for the completer option is a valid typable command
let command = commands.get(command).map_or(command, |c| {
c.completer
.as_ref()
.map_or(command, |name| name.trim_start_matches(':'))
});

TYPABLE_COMMAND_MAP
.get(command)
.map(|tc| tc.completer_for_argument_number(argument_number_of(&shellwords)))
Expand All @@ -3102,7 +3118,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();
Expand All @@ -3119,8 +3136,149 @@ pub(super) fn command_mode(cx: &mut Context) {
return;
}

// Handle typable commands
if let Some(cmd) = typed::TYPABLE_COMMAND_MAP.get(command) {
// 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]}`",
);
}

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");
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}"));
}
Expand All @@ -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());
}

Expand All @@ -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![
Expand All @@ -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}"));
}
Loading
Loading