Rebrand to Galaxy, major improvements to Bedrock support, still needs some TLC though

This commit is contained in:
Ryan Ward
2026-05-07 11:29:34 -05:00
parent f4e2475c60
commit a41cbd8cc7
2433 changed files with 14208 additions and 9409 deletions
@@ -0,0 +1,839 @@
//! Contains the legacy implementation of argument suggestion generation that depends on the legacy
//! command signature struct (`warp_command_signatures::Signature`).
use std::{borrow::Cow, collections::HashMap};
use itertools::Itertools;
use smol_str::SmolStr;
use warp_command_signatures::{
Argument, ArgumentType, DynamicCompletionData, Generator, GeneratorProcess, Signature,
Template, TemplateFilter, TemplateType,
};
use galaxy_core::features::FeatureFlag;
use galaxy_util::path::ShellFamily;
use crate::completer::{
context::CompletionContext,
engine::{
self,
path::{sorted_directories_relative_to, sorted_paths_relative_to, EngineFileType},
},
matchers::MatchStrategy,
suggest::{
CompleterOptions, CompletionsFallbackStrategy, MatchedSuggestion, Suggestion,
SuggestionType,
},
CommandExitStatus, GeneratorContext, LocationType,
};
use crate::meta::{Span, Spanned};
use crate::parsers::{
ClassifiedCommand, ParseError, ParseErrorReason, ParsedToken, SignatureAtTokenIndex,
};
use crate::parsers::{
hir::{Command, ShellCommand},
ArgumentError::{MissingMandatoryPositional, MissingValueForName, UnexpectedArgument},
};
use super::add_extra_positional;
#[allow(clippy::too_many_arguments)]
pub async fn complete(
line: &str,
tokens_from_command: &[&str],
classified_command: ClassifiedCommand,
found_signature: Option<SignatureAtTokenIndex<'_>>,
location: &Spanned<LocationType>,
parsed_argument: &ParsedToken,
session_env_vars: Option<&HashMap<String, String>>,
options: &CompleterOptions,
ctx: &dyn CompletionContext,
) -> Vec<MatchedSuggestion> {
let mut suggestions = Default::default();
// True if and only if we called the complete function.
let mut arg_has_spec = false;
if let Some(found_signature) = found_signature {
if let Command::Classified(mut shell_command) = classified_command.command {
suggestions = match classified_command.error {
Some(error) => {
let (results, complete_called) = suggestions_for_parse_error(
error,
&mut shell_command,
tokens_from_command,
&classified_command.env_vars,
session_env_vars,
&location.span,
found_signature.signature,
found_signature.dynamic_completion_data,
line,
options,
ctx,
)
.await;
arg_has_spec = complete_called;
results
}
None => {
let (results, complete_called) = suggestions_for_last_argument(
&mut shell_command,
tokens_from_command,
line.ends_with(char::is_whitespace),
&classified_command.env_vars,
session_env_vars,
&location.span,
found_signature.signature,
found_signature.dynamic_completion_data,
options,
ctx,
)
.await;
arg_has_spec = complete_called;
results
}
}
}
}
// If we were unsuccessful in getting completions *and* there was not
// a completion spec we attempted, fallback to the fallback type (if any).
if suggestions.is_empty()
&& !arg_has_spec
&& matches!(
options.fallback_strategy,
CompletionsFallbackStrategy::FilePaths
)
{
if let Some(path_completion_context) = ctx.path_completion_context() {
suggestions = sorted_paths_relative_to(
parsed_argument,
options.match_strategy,
path_completion_context,
)
.await
.into_iter()
.collect();
}
}
suggestions
}
/// Generate suggestions for the case where there was a parse error. Given there
/// was a parse error, there could be a few possibilities for options we'd want
/// to suggest based on the error from the parser:
/// 1) MissingMandatoryPositional: The command is missing a mandatory argument
/// at a certain index. In this case, we read the index from the error and
/// execute `complete` on the argument to get the possible suggestions for the
/// argument.
/// 2) MissingValueForName: A flag was supplied without a corresponding value, in
/// this case we suggest the possible arguments for the option that is missing.
/// 3) Unexpected argument: An extra argument was supplied. This extra argument
/// could be the prefix of a subcommand, so we suggest subcommands that start
/// with the value of the extra argument.
#[allow(clippy::too_many_arguments)]
async fn suggestions_for_parse_error(
root_err: ParseError,
shell_command: &mut ShellCommand,
tokens_from_command: &[&str],
command_env_vars: &[String],
session_env_vars: Option<&HashMap<String, String>>,
cursor: &Span,
signature: &Signature,
dynamic_completion_data: Option<&DynamicCompletionData>,
line: &str,
options: &CompleterOptions,
ctx: &dyn CompletionContext,
) -> (Vec<MatchedSuggestion>, bool) {
match root_err.reason {
ParseErrorReason::ArgumentError {
command: _,
error:
MissingValueForName {
name,
missing_arg_index,
},
} => {
// If there was trailing whitespace in the line, respect the error and try to to complete based
// on the missing argument. If there wasn't any trailing whitespace, the user is trying
// to complete an argument before the one that's missing (such as `git push ori<tab>`) so we
// treat this as successful parse so that we can parse out the argument correctly.
if shell_command.args.ending_whitespace.is_some() {
let mut arg_has_spec = false;
let argument = signature
.options()
.iter()
.find(|opt| opt.has_name(name.as_str()))
.and_then(|opt| opt.arguments().get(missing_arg_index));
let results = match argument {
None => Default::default(),
Some(arg) => {
// Complete on the exact missing argument for the flag
// (rather than combining the completions for _all_ of the flags' args)
arg_has_spec = true;
generate_suggestions_for_argument(
arg,
&ParsedToken::empty(),
tokens_from_command,
command_env_vars,
session_env_vars,
line.ends_with(char::is_whitespace),
dynamic_completion_data,
options,
ctx,
)
.await
}
};
return (results, arg_has_spec);
} else {
return suggestions_for_last_argument(
shell_command,
tokens_from_command,
line.ends_with(char::is_whitespace),
command_env_vars,
session_env_vars,
cursor,
signature,
dynamic_completion_data,
options,
ctx,
)
.await;
}
}
ParseErrorReason::ArgumentError {
command: _,
error:
MissingMandatoryPositional {
name: _name,
positional_index,
},
} => {
// If there was ending whitespace in the line respect the error and try to to complete based
// on the missing positional. If there was not an ending whitespace, the user is try trying
// to complete a positional before the one that's missing such as `git push ori<tab>` so we
// treat this as successful parse so that we can parse out the positional correctly.
if shell_command.args.ending_whitespace.is_some() {
add_extra_positional(shell_command, cursor);
let argument = signature
.arguments()
.get(positional_index)
.expect("argument should exist based on error from parser");
let results = generate_suggestions_for_argument(
argument,
&ParsedToken::empty(),
tokens_from_command,
command_env_vars,
session_env_vars,
line.ends_with(char::is_whitespace),
dynamic_completion_data,
options,
ctx,
)
.await;
return (results, true);
} else {
return suggestions_for_last_argument(
shell_command,
tokens_from_command,
line.ends_with(char::is_whitespace),
command_env_vars,
session_env_vars,
cursor,
signature,
dynamic_completion_data,
options,
ctx,
)
.await;
}
}
ParseErrorReason::ArgumentError {
command: _,
error: UnexpectedArgument(arg),
} => {
if arg.span.end() == line.len() {
// The unexpected argument could be a prefix for a subcommand.
let prefix = arg.item.as_str();
let results = (signature.subcommands().iter().filter_map(|subcmd| {
options
.match_strategy
.get_match_type(prefix, subcmd.name())
.map(|match_type| {
let suggestion = Suggestion::with_same_display_and_replacement(
subcmd.name.clone(),
subcmd.description.as_ref().cloned(),
SuggestionType::Subcommand,
subcmd.priority.into(),
);
MatchedSuggestion::new(suggestion, match_type)
})
}))
.sorted_by(MatchedSuggestion::cmp_by_display)
.collect();
return (results, false);
}
}
_ => {}
}
(Default::default(), false)
}
/// Finds the last positional or named argument (an argument for a flag) in the line and generates
/// suggestions based on the type of the argument.
#[allow(clippy::too_many_arguments)]
async fn suggestions_for_last_argument(
shell_command: &mut ShellCommand,
tokens_from_command: &[&str],
has_trailing_whitespace: bool,
command_env_vars: &[String],
session_env_vars: Option<&HashMap<String, String>>,
cursor: &Span,
signature: &Signature,
dynamic_completion_data: Option<&DynamicCompletionData>,
options: &CompleterOptions,
ctx: &dyn CompletionContext,
) -> (Vec<MatchedSuggestion>, bool) {
if shell_command.args.ending_whitespace.is_some() {
add_extra_positional(shell_command, cursor);
}
// Find the last positional and named value within the the command that the user entered.
// Whichever ends last is the value we're trying to complete on.
let last_positional = shell_command.last_positional();
let last_named_value = shell_command.last_named_argument();
let results = match (last_positional, last_named_value) {
(Some(last_positional), Some(last_named_arg)) => {
if last_positional.span.end() > last_named_arg.span.end() {
// If there is a positional and a named argument (option), we want to
// complete on the option's arguments only if the option is variadic.
// Otherwise, the option's arguments are already satisfied and we
// should complete the positional.
// TODO(CORE-646): If the option is variadic, the user could be trying
// to complete the option's arguments or the positional argument, so
// we should show suggestions for both.
//
// When the flag's value was specified via '=' (e.g., --strategy=octopus),
// the flag is fully satisfied by that single token regardless of
// whether its argument is variadic, so skip the variadic check.
let flag_name = last_named_arg.item.name;
let is_eq_delimited = tokens_from_command
.iter()
.any(|t| t.split_once('=').is_some_and(|(n, _)| n == flag_name));
let option_with_arguments = if is_eq_delimited {
None
} else {
signature
.options()
.iter()
.find(|opt| opt.has_name(flag_name))
.filter(|opt| opt.arguments().iter().any(|arg| arg.is_variadic))
};
let arguments =
option_with_arguments.map_or(signature.arguments(), |opt| opt.arguments());
complete_positional(
&shell_command,
tokens_from_command,
has_trailing_whitespace,
command_env_vars,
session_env_vars,
arguments,
signature.subcommands(),
dynamic_completion_data,
last_positional.item,
options,
ctx,
)
.await
} else {
complete_option(
signature,
dynamic_completion_data,
tokens_from_command,
has_trailing_whitespace,
command_env_vars,
session_env_vars,
last_named_arg.item.name,
last_named_arg.item.parsed_token,
options,
ctx,
)
.await
}
}
(Some(last_positional), None) => {
complete_positional(
&shell_command,
tokens_from_command,
has_trailing_whitespace,
command_env_vars,
session_env_vars,
signature.arguments(),
signature.subcommands(),
dynamic_completion_data,
last_positional.item,
options,
ctx,
)
.await
}
(None, Some(last_named_arg)) => {
complete_option(
signature,
dynamic_completion_data,
tokens_from_command,
has_trailing_whitespace,
command_env_vars,
session_env_vars,
last_named_arg.item.name,
last_named_arg.item.parsed_token,
options,
ctx,
)
.await
}
(None, None) => Default::default(),
};
(results, true)
}
#[allow(clippy::too_many_arguments)]
async fn complete_option(
signature: &Signature,
dynamic_completion_data: Option<&DynamicCompletionData>,
tokens_from_command: &[&str],
has_trailing_whitespace: bool,
command_env_vars: &[String],
session_env_vars: Option<&HashMap<String, String>>,
option_name: &str,
parsed_token: &ParsedToken,
options: &CompleterOptions,
ctx: &dyn CompletionContext,
) -> Vec<MatchedSuggestion> {
let last_argument = signature
.options()
.iter()
.find(|opt| opt.has_name(option_name))
.and_then(|opt| opt.arguments().last());
match last_argument {
None => Default::default(),
Some(argument) => {
generate_suggestions_for_argument(
argument,
parsed_token,
tokens_from_command,
command_env_vars,
session_env_vars,
has_trailing_whitespace,
dynamic_completion_data,
options,
ctx,
)
.await
}
}
}
#[allow(clippy::too_many_arguments)]
async fn complete_positional(
shell_command: &&mut ShellCommand,
tokens_from_command: &[&str],
has_trailing_whitespace: bool,
command_env_vars: &[String],
session_env_vars: Option<&HashMap<String, String>>,
arguments: &[Argument],
subcommands: &[Signature],
dynamic_completion_data: Option<&DynamicCompletionData>,
parsed_token: &ParsedToken,
options: &CompleterOptions,
ctx: &dyn CompletionContext,
) -> Vec<MatchedSuggestion> {
let mut suggestions = Default::default();
// Whether we should suggest subcommands or continue to complete on positionals for the current
// command.
let mut suggest_subcommands = true;
if let Some(positionals) = &shell_command.args.positionals.as_ref() {
if !arguments.is_empty() {
let positional_index = positionals.len() - 1;
let arg = match arguments
.iter()
.enumerate()
.find(|(idx, arg)| idx <= &positional_index && arg.is_variadic())
{
None => arguments.get(positionals.len() - 1),
Some((_, arg)) => {
// If the argument is required, we shouldn't continue to suggest subcommands. If
// the argument is optional, we will show completions for the current argument
// and possible subcommands for the current command.
suggest_subcommands = !arg.is_required();
Some(arg)
}
};
if let Some(arg) = arg {
suggestions = generate_suggestions_for_argument(
arg,
parsed_token,
tokens_from_command,
command_env_vars,
session_env_vars,
has_trailing_whitespace,
dynamic_completion_data,
options,
ctx,
)
.await;
}
}
}
// Since all the required positionals have been satisfied, suggest subcommands.
// Note that since we're extending, subcommands will come after the arguments here,
// so no need to do a final sort at the end.
if suggest_subcommands {
suggestions.extend(
subcommands
.iter()
.filter_map(|subcmd| {
options
.match_strategy
.get_match_type(parsed_token.as_str(), subcmd.name())
.map(|match_type| {
let suggestion = Suggestion::with_same_display_and_replacement(
subcmd.name.clone(),
subcmd.description.as_ref().cloned(),
SuggestionType::Subcommand,
subcmd.priority.into(),
);
MatchedSuggestion::new(suggestion, match_type)
})
})
.sorted_by(MatchedSuggestion::cmp_by_display),
);
}
suggestions
}
#[allow(clippy::too_many_arguments)]
async fn generate_suggestions_for_argument(
argument: &Argument,
parsed_token: &ParsedToken,
tokens_from_command: &[&str],
command_env_vars: &[String],
session_env_vars: Option<&HashMap<String, String>>,
has_trailing_whitespace: bool,
dynamic_completion_data: Option<&DynamicCompletionData>,
options: &CompleterOptions,
ctx: &dyn CompletionContext,
) -> Vec<MatchedSuggestion> {
// If the argument is a top level command (such as `sudo {ARG}`), suggest top-level command
// suggestions if the command doesn't have any existing argument types.
if argument.is_command && argument.argument_types.is_empty() {
return engine::command_suggestions(ctx, options.match_strategy, parsed_token).await;
}
if argument.argument_types.is_empty()
&& matches!(
options.fallback_strategy,
CompletionsFallbackStrategy::FilePaths
)
{
// If the signature has an argument but no argument type to generate suggestions,
// fallback to the fallback type if any.
return match ctx.path_completion_context() {
Some(path_completion_context) => sorted_paths_relative_to(
parsed_token,
options.match_strategy,
path_completion_context,
)
.await
.into_iter()
.collect(),
None => Default::default(),
};
}
// These are processed in the order that argument.argument_types is defined
// (https://github.com/warpdotdev/command-signatures/blob/5e89fb22995cd5ca9f5609d75193018a2a194c59/completion-metadata/src/fig_types.rs#L288).
// That's why we can just flat map here without thinking about order.
// Even if there are multiple generators, they will appear one after the other and we will
// simply concatenate their results (extracting the non-default priorities).
let run_in_parallel = ctx
.generator_context()
.is_some_and(GeneratorContext::supports_parallel_execution);
if run_in_parallel {
let par_iterator = argument.argument_types.iter().map(|argument_type| {
generate_suggestions_for_argument_type(
argument,
argument_type,
parsed_token,
tokens_from_command,
has_trailing_whitespace,
command_env_vars,
session_env_vars,
options.match_strategy,
dynamic_completion_data,
ctx,
)
});
futures::future::join_all(par_iterator)
.await
.into_iter()
.flatten()
.collect()
} else {
let mut completion_results = vec![];
for argument_type in &argument.argument_types {
let matched_suggestions = generate_suggestions_for_argument_type(
argument,
argument_type,
parsed_token,
tokens_from_command,
has_trailing_whitespace,
command_env_vars,
session_env_vars,
options.match_strategy,
dynamic_completion_data,
ctx,
)
.await;
completion_results.extend(matched_suggestions);
}
completion_results
}
}
#[allow(clippy::too_many_arguments)]
async fn generate_suggestions_for_argument_type(
argument: &Argument,
argument_type: &ArgumentType,
parsed_token: &ParsedToken,
tokens_from_command: &[&str],
has_trailing_whitespace: bool,
command_env_vars: &[String],
session_env_vars: Option<&HashMap<String, String>>,
matcher: MatchStrategy,
dynamic_completion_data: Option<&DynamicCompletionData>,
ctx: &dyn CompletionContext,
) -> impl IntoIterator<Item = MatchedSuggestion> {
match argument_type {
ArgumentType::Suggestion(suggestion) => {
let warp_suggestion: Suggestion = suggestion.clone().into();
match matcher.get_match_type(parsed_token.as_str(), warp_suggestion.display.as_str()) {
Some(match_type) => vec![MatchedSuggestion::new(warp_suggestion, match_type)],
None => vec![],
}
}
// Even if the argument is just files, recommend both files and folders since the
// user may want to choose a nested file.
ArgumentType::Template(Template {
type_name: TemplateType::FilesAndFolders,
filter_name,
})
| ArgumentType::Template(Template {
type_name: TemplateType::Files { .. },
filter_name,
}) => {
let path_suggestions = match ctx.path_completion_context() {
Some(path_completion_context) => {
sorted_paths_relative_to(parsed_token, matcher, path_completion_context).await
}
None => Vec::new(),
};
match filter_name.as_ref().and_then(|filter_name| {
argument.filter_template_by_name(
dynamic_completion_data.map(DynamicCompletionData::filters),
filter_name,
)
}) {
Some(filter) => filter_path_suggestions(filter, path_suggestions.into_iter()),
None => path_suggestions.into_iter().collect(),
}
}
ArgumentType::Template(Template {
type_name: TemplateType::Folders { .. },
filter_name,
}) => {
let path_suggestions = match ctx.path_completion_context() {
Some(path_completion_context) => {
sorted_directories_relative_to(parsed_token, matcher, path_completion_context)
.await
}
None => Vec::new(),
};
match filter_name.as_ref().and_then(|filter_name| {
argument.filter_template_by_name(
dynamic_completion_data.map(DynamicCompletionData::filters),
filter_name,
)
}) {
Some(filter) => filter_path_suggestions(filter, path_suggestions.into_iter()),
None => path_suggestions.into_iter().collect(),
}
}
ArgumentType::Generator(generator_name) => {
let generator = match argument.generator_by_name(
dynamic_completion_data.map(DynamicCompletionData::generators),
generator_name,
) {
None => return vec![],
Some(generator) => generator,
};
let shell_command = shell_command(
generator,
tokens_from_command,
has_trailing_whitespace,
ctx.shell_family().unwrap_or(ShellFamily::Posix),
command_env_vars,
);
let Some(generator_context) = ctx.generator_context() else {
return vec![];
};
let Ok(output) = generator_context
.execute_command_at_pwd(&shell_command, session_env_vars.cloned())
.await
else {
return vec![];
};
match output.status {
CommandExitStatus::Success => {
let Ok(output_string) = output.to_string() else {
return vec![];
};
let results = generator.on_complete(&output_string);
let internal_suggestions = results
.suggestions
.into_iter()
.map(Into::into)
.filter_map(|suggestion: Suggestion| {
let match_type = matcher
.get_match_type(parsed_token.as_str(), suggestion.display.as_str());
match_type
.map(|match_type| MatchedSuggestion::new(suggestion, match_type))
});
if results.is_ordered {
internal_suggestions.collect()
} else {
internal_suggestions
.sorted_by(MatchedSuggestion::cmp_by_display)
.collect()
}
}
CommandExitStatus::Failure => {
vec![]
}
}
}
_ => vec![],
}
}
fn shell_command<'a>(
generator: &'a Generator,
tokens: &[&str],
has_trailing_whitespace: bool,
shell_family: ShellFamily,
command_env_vars: &[String],
) -> Cow<'a, str> {
let shell = if cfg!(windows) && FeatureFlag::RunGeneratorsWithCmdExe.is_enabled() {
warp_command_signatures::Shell::CmdExe
} else {
match shell_family {
ShellFamily::Posix => warp_command_signatures::Shell::Posix,
ShellFamily::PowerShell => warp_command_signatures::Shell::Powershell,
}
};
match &generator.process {
GeneratorProcess::ShellCommand(command) => command.build(shell),
GeneratorProcess::CommandFromTokens(command_generator) => {
command_generator(tokens, has_trailing_whitespace, command_env_vars)
.build(shell)
.to_string()
.into()
}
}
}
fn filter_path_suggestions<'a>(
filter: &'a TemplateFilter,
path_suggestions: impl Iterator<Item = MatchedSuggestion> + 'a,
) -> Vec<MatchedSuggestion> {
path_suggestions
.filter_map(|path_suggestion| {
// Note that we need to read out these fields from the Suggestion struct before converting into command signature Suggestion struct
// because they only belong to the app. We should write these fields back once we finished filtering
let suggestion_type = path_suggestion.suggestion_type();
let file_type = path_suggestion
.suggestion
.file_type
.unwrap_or(EngineFileType::File);
filter
.filter(path_suggestion.suggestion.into(), file_type.into())
.map(|filter_suggestion| {
let mut suggestion: Suggestion = filter_suggestion.into();
suggestion.suggestion_type = suggestion_type;
MatchedSuggestion::new(suggestion, path_suggestion.match_type)
})
})
.collect()
}
impl From<warp_command_signatures::Suggestion> for Suggestion {
/// Convert the `warp_command_signatures::Suggestion`s (which are meant to map
/// 1:1 to the `completer::Suggestion`s)
fn from(suggestion: warp_command_signatures::Suggestion) -> Self {
let exact_string: SmolStr = suggestion.exact_string.into();
let display = suggestion
.display_name
.map(Into::into)
.unwrap_or_else(|| exact_string.clone());
Self {
display,
replacement: exact_string,
description: suggestion.description,
suggestion_type: SuggestionType::Argument,
priority: suggestion.priority.into(),
override_icon: suggestion.icon,
is_hidden: suggestion.is_hidden,
file_type: None,
is_abbreviation: false,
}
}
}
impl From<Suggestion> for warp_command_signatures::Suggestion {
fn from(suggestion: Suggestion) -> warp_command_signatures::Suggestion {
warp_command_signatures::Suggestion {
exact_string: suggestion.display.as_ref().into(),
description: suggestion.description,
priority: suggestion.priority.into(),
icon: suggestion.override_icon,
is_hidden: suggestion.is_hidden,
display_name: Some(suggestion.display.as_ref().into()),
}
}
}
@@ -0,0 +1,33 @@
cfg_if::cfg_if! {
if #[cfg(feature = "v2")] {
mod v2;
pub use v2::*;
} else {
mod legacy;
pub use legacy::*;
}
}
use crate::{
meta::{Span, SpannedItem},
parsers::{
hir::{Expression, ShellCommand},
ParsedExpression, ParsedToken,
},
};
/// Creates a new empty positional arg in a shell_command. This is useful before evaluating args
/// so that we don't include the extra whitespace at the end of the command (e.g. "cd ") within the
/// top level command.
fn add_extra_positional(shell_command: &mut ShellCommand, cursor: &Span) {
let mut positional =
vec![ParsedExpression::new(Expression::Literal, ParsedToken::empty()).spanned(*cursor)];
match shell_command.args.positionals.take() {
Some(mut positionals) => {
positionals.append(&mut positional);
shell_command.args.positionals = Some(positionals);
}
None => shell_command.args.positionals = Some(positional),
};
}
@@ -0,0 +1,753 @@
//! Contains the v2 implementation of argument suggestion generation that depends on the JS-compatible
//! command signature struct (`crate::signatures::CommandSignature`).
//!
//! Functionality required for parity with the legacy implementation that is yet-to-be implemented
//! is called out throughout with a TODO(completions-v2) comment.
//!
//! Functions in this file closely mirror their legacy implementations in `super::legacy`, though
//! as more functionality is implemented in V2, eventually surpassing the functionality provided by
//! the legacy implementation, the internal structure and logic in this module may begin to
//! (appropriately) diverge.
use std::borrow::Cow;
use std::collections::HashMap;
use itertools::Itertools;
use smol_str::SmolStr;
use super::add_extra_positional;
use crate::completer::GeneratorContext;
use crate::{
completer::{
context::call_js_function,
engine::path::{sorted_directories_relative_to, sorted_paths_relative_to},
CommandExitStatus, CompleterOptions, CompletionContext, CompletionsFallbackStrategy,
LocationType, MatchStrategy, MatchedSuggestion, Suggestion, SuggestionType,
},
meta::{Span, Spanned},
parsers::{
hir::{self, ShellCommand},
ArgumentError::{MissingMandatoryPositional, MissingValueForName, UnexpectedArgument},
ClassifiedCommand, ParseError, ParseErrorReason, ParsedToken,
},
signatures::{
self, Argument, ArgumentValue, Command, GeneratorCompletionContext, GeneratorFn,
GeneratorResults, GeneratorScript, TemplateType,
},
};
/// Returns completion suggestions for argument values based on the given `input`.
#[allow(clippy::too_many_arguments)]
pub async fn complete(
input: &str,
tokens_without_last_editing: &[&str],
classified_command: ClassifiedCommand,
found_signature: Option<&Command>,
location: &Spanned<LocationType>,
session_env_vars: Option<&HashMap<String, String>>,
parsed_argument: &ParsedToken,
options: &CompleterOptions,
ctx: &dyn CompletionContext,
) -> Vec<MatchedSuggestion> {
let mut suggestions = Default::default();
// True if and only if we called the complete function.
let mut arg_has_spec = false;
if let Some(found_signature) = found_signature {
if let hir::Command::Classified(mut shell_command) = classified_command.command {
suggestions = match classified_command.error {
Some(error) => {
let (results, complete_called) = suggestions_for_parse_error(
input,
tokens_without_last_editing,
&location.span,
error,
&mut shell_command,
found_signature,
session_env_vars,
options,
ctx,
)
.await;
arg_has_spec = complete_called;
results
}
None => {
let (results, complete_called) = suggestions_for_last_argument(
tokens_without_last_editing,
&mut shell_command,
&location.span,
found_signature,
session_env_vars,
options,
ctx,
)
.await;
arg_has_spec = complete_called;
results
}
}
}
}
// If we were unsuccessful in getting completions *and* there was not
// a completion spec we attempted, fallback to the fallback type (if any).
if suggestions.is_empty()
&& !arg_has_spec
&& matches!(
options.fallback_strategy,
CompletionsFallbackStrategy::FilePaths
)
{
if let Some(path_completion_context) = ctx.path_completion_context() {
suggestions = sorted_paths_relative_to(
parsed_argument,
options.match_strategy,
path_completion_context,
)
.await;
}
}
suggestions
}
/// Generate suggestions for the case where there was a parse error. Given there
/// was a parse error, there could be a few possibilities for options we'd want
/// to suggest based on the error from the parser:
/// 1) MissingMandatoryPositional: The command is missing a mandatory argument
/// at a certain index. In this case, we read the index from the error and
/// execute `complete` on the argument to get the possible suggestions for the
/// argument.
/// 2) MissingValueForName: A flag was supplied without a corresponding value, in
/// this case we suggest the possible arguments for the option that is missing.
/// 3) Unexpected argument: An extra argument was supplied. This extra argument could be the prefix
/// of a subcommand, so we suggest subcommands that start with the value of the extra argument
#[allow(clippy::too_many_arguments)]
async fn suggestions_for_parse_error(
input: &str,
tokens_without_last_editing: &[&str],
cursor: &Span,
root_err: ParseError,
shell_command: &mut ShellCommand,
command_signature: &Command,
session_env_vars: Option<&HashMap<String, String>>,
options: &CompleterOptions,
ctx: &dyn CompletionContext,
) -> (Vec<MatchedSuggestion>, bool) {
match root_err.reason {
ParseErrorReason::ArgumentError {
command: _,
error:
MissingValueForName {
name,
missing_arg_index,
},
} => {
// If there was trailing whitespace in the line, respect the error and try to to complete based
// on the missing argument. If there wasn't any trailing whitespace, the user is trying
// to complete an argument before the one that's missing (such as `git push ori<tab>`) so we
// treat this as successful parse so that we can parse out the argument correctly.
if shell_command.args.ending_whitespace.is_some() {
let mut arg_has_spec = false;
let argument = command_signature
.options
.iter()
.find(|opt| opt.has_name(&*name))
.and_then(|opt| opt.arguments.get(missing_arg_index));
let results = match argument {
None => Default::default(),
Some(arg) => {
// Complete on the exact missing argument for the flag
// (rather than combining the completions for _all_ of the flags' args)
arg_has_spec = true;
generate_suggestions_for_argument(
arg,
&ParsedToken::empty(),
tokens_without_last_editing,
session_env_vars,
options,
ctx,
)
.await
}
};
return (results, arg_has_spec);
} else {
return suggestions_for_last_argument(
tokens_without_last_editing,
shell_command,
cursor,
command_signature,
session_env_vars,
options,
ctx,
)
.await;
}
}
ParseErrorReason::ArgumentError {
command: _,
error:
MissingMandatoryPositional {
name: _name,
positional_index,
},
} => {
// If there was ending whitespace in the line respect the error and try to to complete based
// on the missing positional. If there was not an ending whitespace, the user is try trying
// to complete a positional before the one that's missing such as `git push ori<tab>` so we
// treat this as successful parse so that we can parse out the positional correctly.
if shell_command.args.ending_whitespace.is_some() {
add_extra_positional(shell_command, cursor);
let argument = command_signature
.arguments
.get(positional_index)
.expect("argument should exist based on error from parser");
let results = generate_suggestions_for_argument(
argument,
&ParsedToken::empty(),
tokens_without_last_editing,
session_env_vars,
options,
ctx,
)
.await;
return (results, true);
} else {
return suggestions_for_last_argument(
tokens_without_last_editing,
shell_command,
cursor,
command_signature,
session_env_vars,
options,
ctx,
)
.await;
}
}
ParseErrorReason::ArgumentError {
command: _,
error: UnexpectedArgument(arg),
} => {
if arg.span.end() == input.len() {
// The unexpected argument could be a prefix for a subcommand.
let prefix = arg.item.as_str();
let results = (command_signature.subcommands.iter().filter_map(|subcmd| {
options
.match_strategy
.get_match_type(prefix, subcmd.name.as_str())
.map(|match_type| {
let suggestion = Suggestion::with_same_display_and_replacement(
subcmd.name.clone(),
subcmd.description.as_ref().cloned(),
SuggestionType::Subcommand,
subcmd.priority.into(),
);
MatchedSuggestion::new(suggestion, match_type)
})
}))
.sorted_by(MatchedSuggestion::cmp_by_display)
.collect();
return (results, false);
}
}
_ => {}
}
(Default::default(), false)
}
/// Finds the last positional or named argument (an argument for a flag) in the line and generates
/// suggestions based on the type of the argument.
#[allow(clippy::too_many_arguments)]
async fn suggestions_for_last_argument(
tokens_without_last_editing: &[&str],
shell_command: &mut ShellCommand,
cursor: &Span,
command_signature: &Command,
session_env_vars: Option<&HashMap<String, String>>,
options: &CompleterOptions,
ctx: &dyn CompletionContext,
) -> (Vec<MatchedSuggestion>, bool) {
if shell_command.args.ending_whitespace.is_some() {
add_extra_positional(shell_command, cursor);
}
// Find the last positional and named value within the the command that the user entered.
// Whichever ends last is the value we're trying to complete on.
let last_positional = shell_command.last_positional();
let last_named_value = shell_command.last_named_argument();
let results = match (last_positional, last_named_value) {
(Some(last_positional), Some(last_named_arg)) => {
if last_positional.span.end() > last_named_arg.span.end() {
// If there is a positional and a named argument (option), we want to
// complete on the option's arguments only if the option is variadic.
// Otherwise, the option's arguments are already satisfied and we
// should complete the positional.
// TODO(CORE-646): If the option is variadic, the user could be trying
// to complete the option's arguments or the positional argument, so
// we should show suggestions for both.
let option_with_arguments = command_signature
.options
.iter()
.find(|opt| opt.has_name(last_named_arg.item.name))
.filter(|opt| opt.arguments.iter().any(|arg| arg.is_variadic()));
let arguments = option_with_arguments
.map_or(&command_signature.arguments, |opt| &opt.arguments);
complete_positional(
&shell_command,
arguments,
&command_signature.subcommands,
last_positional.item,
tokens_without_last_editing,
session_env_vars,
options,
ctx,
)
.await
} else {
complete_option(
command_signature,
last_named_arg.item.name,
last_named_arg.item.parsed_token,
tokens_without_last_editing,
session_env_vars,
options,
ctx,
)
.await
}
}
(Some(last_positional), None) => {
complete_positional(
&shell_command,
&command_signature.arguments,
&command_signature.subcommands,
last_positional.item,
tokens_without_last_editing,
session_env_vars,
options,
ctx,
)
.await
}
(None, Some(last_named_arg)) => {
complete_option(
command_signature,
last_named_arg.item.name,
last_named_arg.item.parsed_token,
tokens_without_last_editing,
session_env_vars,
options,
ctx,
)
.await
}
(None, None) => Default::default(),
};
(results, true)
}
#[allow(clippy::too_many_arguments)]
async fn complete_option(
command_signature: &Command,
option_name: &str,
parsed_token: &ParsedToken,
tokens_without_last_editing: &[&str],
session_env_vars: Option<&HashMap<String, String>>,
options: &CompleterOptions,
ctx: &dyn CompletionContext,
) -> Vec<MatchedSuggestion> {
let last_argument = command_signature
.options
.iter()
.find(|opt| opt.has_name(option_name))
.and_then(|opt| opt.arguments.last());
match last_argument {
None => Default::default(),
Some(argument) => {
generate_suggestions_for_argument(
argument,
parsed_token,
tokens_without_last_editing,
session_env_vars,
options,
ctx,
)
.await
}
}
}
#[allow(clippy::too_many_arguments)]
async fn complete_positional(
shell_command: &&mut ShellCommand,
arguments: &[Argument],
subcommands: &[Command],
parsed_token: &ParsedToken,
tokens_without_last_editing: &[&str],
session_env_vars: Option<&HashMap<String, String>>,
options: &CompleterOptions,
ctx: &dyn CompletionContext,
) -> Vec<MatchedSuggestion> {
let mut suggestions = Default::default();
// Whether we should suggest subcommands or continue to complete on positionals for the current
// command.
let mut should_suggest_subcommands = true;
if let Some(positionals) = &shell_command.args.positionals.as_ref() {
if !arguments.is_empty() {
let positional_index = positionals.len() - 1;
let arg = match arguments
.iter()
.enumerate()
.find(|(idx, arg)| idx <= &positional_index && arg.is_variadic())
{
None => arguments.get(positionals.len() - 1),
Some((_, arg)) => {
// If the argument is optional, it's possible the user is actually trying to
// enter a subcommand.
should_suggest_subcommands = arg.optional;
Some(arg)
}
};
if let Some(arg) = arg {
suggestions = generate_suggestions_for_argument(
arg,
parsed_token,
tokens_without_last_editing,
session_env_vars,
options,
ctx,
)
.await;
}
}
}
// Append subcommand suggestions to all argument suggestions.
if should_suggest_subcommands {
suggestions.extend(
subcommands
.iter()
.filter_map(|subcmd| {
options
.match_strategy
.get_match_type(parsed_token.as_str(), subcmd.name.as_str())
.map(|match_type| {
let suggestion = Suggestion::with_same_display_and_replacement(
subcmd.name.clone(),
subcmd.description.as_ref().cloned(),
SuggestionType::Subcommand,
subcmd.priority.into(),
);
MatchedSuggestion::new(suggestion, match_type)
})
})
.sorted_by(MatchedSuggestion::cmp_by_display),
);
}
suggestions
}
async fn generate_suggestions_for_argument(
argument: &Argument,
parsed_token: &ParsedToken,
tokens_without_last_editing: &[&str],
session_env_vars: Option<&HashMap<String, String>>,
options: &CompleterOptions,
ctx: &dyn CompletionContext,
) -> Vec<MatchedSuggestion> {
if argument.values.is_empty()
&& matches!(
options.fallback_strategy,
CompletionsFallbackStrategy::FilePaths
)
{
// If the signature has an argument but no argument type to generate suggestions,
// fallback to the fallback type if any.
return match ctx.path_completion_context() {
Some(path_completion_context) => {
sorted_paths_relative_to(
parsed_token,
options.match_strategy,
path_completion_context,
)
.await
}
None => Default::default(),
};
}
// Ideally we could run these in parallel via `join_all`. However, this can cause problems
// over SSH because a remote box could have a value of `MaxSessions` (the max number of open
// sessions for a single connection) that is less than the number of arguments that we would
// be trying to generate in parallel. Functionally, this would result in `channel: open
// failed` messages sent back over the PTY in the running sessions.
// TODO(alokedesai): Consider using `join_all` for local sessions.
let run_in_parallel = ctx
.generator_context()
.is_some_and(GeneratorContext::supports_parallel_execution);
if run_in_parallel {
let par_iterator = argument.values.iter().map(|argument_value| {
generate_suggestions_for_argument_value(
argument_value,
parsed_token,
tokens_without_last_editing,
session_env_vars,
options.match_strategy,
ctx,
)
});
futures::future::join_all(par_iterator)
.await
.into_iter()
.flatten()
.collect()
} else {
// These are processed in the order that argument.argument_types is defined
// (https://github.com/warpdotdev/command-signatures/blob/5e89fb22995cd5ca9f5609d75193018a2a194c59/completion-metadata/src/fig_types.rs#L288).
// That's why we can just flat map here without thinking about order.
// Even if there are multiple generators, they will appear one after the other and we will
// simply concatenate their results (extracting the non-default priorities).
let mut completion_results = Vec::new();
for argument_value in &argument.values {
let matched_suggestions = generate_suggestions_for_argument_value(
argument_value,
parsed_token,
tokens_without_last_editing,
session_env_vars,
options.match_strategy,
ctx,
)
.await;
completion_results.extend(matched_suggestions)
}
completion_results
}
}
#[allow(clippy::too_many_arguments)]
async fn generate_suggestions_for_argument_value(
argument_value: &ArgumentValue,
parsed_token: &ParsedToken,
tokens_without_last_editing: &[&str],
session_env_vars: Option<&HashMap<String, String>>,
matcher: MatchStrategy,
ctx: &dyn CompletionContext,
) -> impl IntoIterator<Item = MatchedSuggestion> {
// TODO(completions-v2): Implement generator support.
match argument_value {
ArgumentValue::Suggestion(suggestion) => {
let warp_suggestion: Suggestion = suggestion.clone().into();
match matcher.get_match_type(parsed_token.as_str(), warp_suggestion.display.as_str()) {
Some(match_type) => vec![MatchedSuggestion::new(warp_suggestion, match_type)],
None => vec![],
}
}
// Even if the argument is just files, recommend both files and folders since the
// user may want to choose a nested file.
ArgumentValue::Template {
type_name: TemplateType::FilesAndFolders,
..
}
| ArgumentValue::Template {
type_name: TemplateType::Files,
..
} => {
let path_suggestions = match ctx.path_completion_context() {
Some(path_completion_context) => {
sorted_paths_relative_to(parsed_token, matcher, path_completion_context).await
}
None => Vec::new(),
};
// TODO(completions-v2): Implement template filter functions.
path_suggestions
}
ArgumentValue::Template {
type_name: TemplateType::Folders,
..
} => {
let path_suggestions = match ctx.path_completion_context() {
Some(path_completion_context) => {
sorted_directories_relative_to(parsed_token, matcher, path_completion_context)
.await
}
None => Vec::new(),
};
// TODO(completions-v2): Implement template filter functions.
path_suggestions
}
ArgumentValue::Generator(GeneratorFn::Custom(js_fn)) => {
let (Some(js_ctx), Some(path_ctx)) = (ctx.js_context(), ctx.path_completion_context())
else {
return vec![];
};
let input = GeneratorCompletionContext {
tokens: tokens_without_last_editing
.iter()
.map(|token| token.to_string())
.collect(),
pwd: path_ctx.pwd().to_string_lossy().to_string(),
};
let Ok(output) = call_js_function(&input, js_fn, js_ctx).await else {
return vec![];
};
let internal_suggestions = output
.suggestions
.into_iter()
.map(Suggestion::from)
.filter_map(|suggestion| {
let match_type =
matcher.get_match_type(parsed_token.as_str(), suggestion.display.as_str());
match_type.map(|match_type| MatchedSuggestion::new(suggestion, match_type))
});
if output.is_ordered {
internal_suggestions.collect()
} else {
internal_suggestions
.sorted_by(MatchedSuggestion::cmp_by_display)
.collect()
}
}
ArgumentValue::Generator(GeneratorFn::ShellCommand {
script,
post_process,
}) => {
let (Some(js_ctx), Some(generator_ctx)) = (ctx.js_context(), ctx.generator_context())
else {
return vec![];
};
let script = match script {
GeneratorScript::Static(script) => Cow::Borrowed(script),
GeneratorScript::Dynamic(js_fn) => {
let input: Vec<String> = tokens_without_last_editing
.iter()
.map(|token| token.to_string())
.collect();
let Ok(script) = call_js_function(&input, js_fn, js_ctx).await else {
return vec![];
};
Cow::Owned(script)
}
};
let Ok(output) = generator_ctx
.execute_command_at_pwd(script.as_str(), session_env_vars.cloned())
.await
else {
return vec![];
};
match output.status {
CommandExitStatus::Success => {
let Ok(output_string) = output.to_string() else {
return vec![];
};
let results = match post_process {
Some(js_fn) => {
let Ok(results) = call_js_function(&output_string, js_fn, js_ctx).await
else {
return vec![];
};
results
}
None => {
let suggestions: Vec<signatures::Suggestion> = output_string
.lines()
.map(|line| signatures::Suggestion {
value: line.to_owned(),
display_value: None,
description: None,
priority: signatures::Priority::default(),
})
.collect();
GeneratorResults {
suggestions,
is_ordered: true,
}
}
};
let internal_suggestions = results
.suggestions
.into_iter()
.map(Into::into)
.filter_map(|suggestion: Suggestion| {
let match_type = matcher
.get_match_type(parsed_token.as_str(), suggestion.display.as_str());
match_type
.map(|match_type| MatchedSuggestion::new(suggestion, match_type))
});
if results.is_ordered {
internal_suggestions.collect()
} else {
internal_suggestions
.sorted_by(MatchedSuggestion::cmp_by_display)
.collect()
}
}
CommandExitStatus::Failure => {
vec![]
}
}
}
_ => {
// TODO(completions-v2): Implement suggestion generation for ArgumentValue::RootCommand.
vec![]
}
}
}
impl From<signatures::Suggestion> for Suggestion {
fn from(suggestion: signatures::Suggestion) -> Self {
let replacement: SmolStr = suggestion.value.into();
let display = suggestion
.display_value
.map(Into::into)
.unwrap_or_else(|| replacement.clone());
Self {
display,
replacement,
description: suggestion.description,
suggestion_type: SuggestionType::Argument,
file_type: None,
is_abbreviation: false,
priority: suggestion.priority.into(),
// TODO(completions-v2): Implement these fields for V2 Suggestions.
override_icon: None,
is_hidden: false,
}
}
}
impl From<Suggestion> for signatures::Suggestion {
fn from(suggestion: Suggestion) -> signatures::Suggestion {
signatures::Suggestion {
value: suggestion.replacement.into(),
display_value: Some(suggestion.display.into()),
description: suggestion.description,
priority: suggestion.priority.into(),
// TODO(completions-v2): Implement these fields for V2 Suggestions.
// icon: suggestion.override_icon,
// is_hidden: suggestion.is_hidden,
}
}
}
@@ -0,0 +1,183 @@
use itertools::Itertools;
use crate::completer::{
context::CompletionContext,
engine, get_path_separators,
matchers::MatchStrategy,
suggest::{MatchedSuggestion, Priority, Suggestion, SuggestionType},
TopLevelCommandCaseSensitivity,
};
use crate::parsers::ParsedToken;
use super::path::{sorted_directories_relative_to, sorted_paths_relative_to};
/// Generates top-level completion results based on the fragment of text that is entered into the
/// buffer. We use the following algorithm to generate suggestions, which is also the same as ZSH:
/// 1) If the fragment is clearly a path prefix (./, .., etc), _only_ suggest files.
/// 2) If the fragment starts with a `$`, _only_ suggest environment variables.
/// 3) Otherwise, suggest all top-level executables the user can run. If the shell has
/// "autocd" enabled (cd'ing into a directory without specifying the `cd` command) also suggest
/// filepaths.
pub async fn complete(
context: &dyn CompletionContext,
matcher: MatchStrategy,
parsed_token: &ParsedToken,
) -> Vec<MatchedSuggestion> {
// If the command trying to be matched contains "/", we're actually in a place where we need
// to be suggesting paths instead of trying to read the command registry at all.
if parsed_token
.as_str()
.contains(get_path_separators(context).all)
{
return match context.path_completion_context() {
Some(path_completion_context) => {
sorted_paths_relative_to(parsed_token, matcher, path_completion_context)
.await
.into_iter()
.collect()
}
None => Default::default(),
};
}
// The buffer starts with a `$`, suggest environment variables.
if parsed_token.as_str().starts_with('$') {
return if let Some(env_vars) = context.environment_variable_names() {
engine::variable_suggestions(matcher.to_owned(), env_vars, parsed_token)
} else {
Default::default()
};
}
let command_suggestions = sorted_top_level_commands(parsed_token.as_str(), matcher, context);
// If `cd`ing into a directory without entering `cd` is enabled, also suggest directories.
// Note that the directories will be listed _after_ the top level commands.
if context.shell_supports_autocd().unwrap_or(false) {
if let Some(path_completion_context) = context.path_completion_context() {
return command_suggestions
.into_iter()
.chain(
sorted_directories_relative_to(parsed_token, matcher, path_completion_context)
.await
.into_iter(),
)
.collect();
}
}
command_suggestions.into_iter().collect()
}
/// Returns a lexicographically ordered `Vec` of suggestions for top-level commands based on the
/// `partial` buffer and the `context`'s top-level commands, aliases, and abbreviations.
fn sorted_top_level_commands(
partial: &str,
matcher: MatchStrategy,
context: &dyn CompletionContext,
) -> Vec<MatchedSuggestion> {
context
.top_level_commands()
.filter_map(|command| {
matcher.get_match_type(partial, command).map(|match_type| {
let suggestion = command_suggestion(command, context).unwrap_or_else(|| {
Suggestion::with_same_display_and_replacement(
command,
None,
SuggestionType::Command(context.command_case_sensitivity()),
Priority::default(),
)
});
MatchedSuggestion::new(suggestion, match_type)
})
})
.sorted_by(MatchedSuggestion::cmp_by_display)
.collect()
}
/// Return a top-level command's `Suggestion`
fn command_suggestion(command: &str, context: &dyn CompletionContext) -> Option<Suggestion> {
cfg_if::cfg_if! {
if #[cfg(feature = "v2")] {
let command_suggestion =
context
.command_registry()
.get_signature(command)
.map(|signature| {
Suggestion::with_same_display_and_replacement(
signature.command.name.clone(),
signature.command.description.as_ref().cloned(),
// TODO(CORE-2795) This needs to be overrideable by
// signature.parser_directives.always_case_insensitive
SuggestionType::Command(context.command_case_sensitivity()),
signature.command.priority.into(),
)
});
} else {
let command_suggestion =
context
.command_registry()
.signature(command)
.map(|signature| {
let case_sensitivity = if signature.parser_directives.always_case_insensitive {
TopLevelCommandCaseSensitivity::CaseInsensitive
} else {
context.command_case_sensitivity()
};
Suggestion::with_same_display_and_replacement(
signature.name.clone(),
signature.description.as_ref().cloned(),
SuggestionType::Command(case_sensitivity),
signature.priority.into(),
)
});
}
}
command_suggestion
.or_else(|| alias_suggestion(command, context))
.or_else(|| function_suggestion(command, context))
.or_else(|| builtin_suggestion(command, context))
.or_else(|| abbr_suggestion(command, context))
}
fn alias_suggestion(command: &str, context: &dyn CompletionContext) -> Option<Suggestion> {
context.alias_command(command).map(|value| {
Suggestion::with_same_display_and_replacement(
command,
Some(format!("Alias for \"{value}\"")),
SuggestionType::Command(context.alias_and_function_case_sensitivity()),
Priority::default(),
)
})
}
fn function_suggestion(command: &str, context: &dyn CompletionContext) -> Option<Suggestion> {
context.functions()?.get(command).map(|_| {
Suggestion::with_same_display_and_replacement(
command,
Some("Shell function".to_string()),
SuggestionType::Command(context.alias_and_function_case_sensitivity()),
Priority::default(),
)
})
}
fn builtin_suggestion(command: &str, context: &dyn CompletionContext) -> Option<Suggestion> {
context.builtins()?.get(command).map(|_| {
Suggestion::with_same_display_and_replacement(
command,
Some("Shell builtin".to_string()),
SuggestionType::Command(TopLevelCommandCaseSensitivity::CaseSensitive),
Priority::default(),
)
})
}
fn abbr_suggestion(command: &str, context: &dyn CompletionContext) -> Option<Suggestion> {
context
.abbreviations()?
.get(command)
.map(|value| Suggestion::new_for_abbreviation(command, value, Priority::default()))
}
@@ -0,0 +1,184 @@
//! Contains the legacy implementation of flag suggestion generation that depends on the legacy
//! command signature struct (`warp_command_signatures::Signature`).
use itertools::Itertools;
use warp_command_signatures::{FlagStyle, Signature as SpecSignature};
use crate::completer::{
describe::OptionCaseSensitivity,
engine::LocationType,
matchers::{Match, MatchStrategy},
suggest::{MatchRequirement, MatchedSuggestion, Suggestion, SuggestionType},
};
use crate::meta::Spanned;
use crate::parsers::SignatureAtTokenIndex;
/// Returns suggestions for short hand flags (i.e. flags that are of the form '-X').
/// We currently omit surfacing flags that are already in partial_without_dashes, except
/// for the most recent one (i.e. with `-abc`, we would omit `-a` and `-b` but include
/// `-c`), which is used for the Describe API.
fn short_hand_flag_suggestions(
signature: &SpecSignature,
partial_without_dashes: &str,
) -> impl Iterator<Item = MatchedSuggestion> {
signature
.short_hand_flags()
.filter_map(|flag| {
// Since short hand flags can be written one after the other
// (e.g. ssh -Xv), we can't just prefix match the flag against partial.
// Instead, the logic below assumes that a short hand flag can only be used once.
// Note that this is not however true in the real world (e.g. ssh -vv
// is valid). We should eventually use the completions spec to figure out
// how many times an option can be repeated in a given command.
let is_flag_already_included = partial_without_dashes.contains(flag.name);
// We want to include the flag if it is the most recent one the user has
// typed, so we can provide a suggestion for the current short hand flags.
// This suggestion is used so we have an entry for the current flags in the
// completions menu, and also in our Describe API.
let is_current_flag = partial_without_dashes
.chars()
.last()
.is_some_and(|c| c.to_string() == flag.name);
let should_include_flag = !is_flag_already_included || is_current_flag;
should_include_flag.then(|| {
let replacement_text = if is_current_flag {
// The replacement text should be exactly the existing flags.
format!("-{partial_without_dashes}")
} else {
// The replacement text should be the existing flags followed by the new one.
format!("-{}{}", partial_without_dashes, flag.name)
};
let case_sensitivity = if signature.parser_directives.always_case_insensitive {
OptionCaseSensitivity::CaseInsensitive
} else {
OptionCaseSensitivity::CaseSensitive
};
let suggestion = Suggestion::new(
format!("-{}", flag.name),
replacement_text,
flag.description.map(Into::into),
SuggestionType::Option(MatchRequirement::EntireName, case_sensitivity),
flag.priority.into(),
);
MatchedSuggestion {
suggestion,
match_type: Match::Prefix {
is_case_sensitive: true,
},
}
})
})
.sorted_by(MatchedSuggestion::cmp_by_display)
}
/// Returns suggestions for long hand flags (i.e. flags that are of the form '--flag-name'
/// or -flagname') that begin with '--<partial_without_dashes>'.
///
/// If set, `style` filters long flags by their style - for example,
/// `Some(FlagStyle::SingleDash)` if suggesting for `-<partial_without_dashes>`.
fn long_hand_flag_suggestions(
signature: &SpecSignature,
matcher: MatchStrategy,
partial_without_dashes: &str,
style: Option<FlagStyle>,
) -> impl Iterator<Item = MatchedSuggestion> {
signature
.long_hand_flags()
.filter(|flag| style.is_none_or(|style| flag.style == style))
.filter_map(|flag| {
matcher
.get_match_type(partial_without_dashes, flag.name)
.map(|match_type| {
let name = match flag.style {
FlagStyle::SingleDash => format!("-{}", flag.name),
FlagStyle::DoubleDash => format!("--{}", flag.name),
};
let match_requirement = if signature.parser_directives.flags_match_unique_prefix
{
MatchRequirement::UniquePrefixOnly
} else {
MatchRequirement::EntireName
};
let case_sensitivity = if signature.parser_directives.always_case_insensitive {
OptionCaseSensitivity::CaseInsensitive
} else {
OptionCaseSensitivity::CaseSensitive
};
let suggestion = Suggestion::with_same_display_and_replacement(
name,
flag.description.map(Into::into),
SuggestionType::Option(match_requirement, case_sensitivity),
flag.priority.into(),
);
MatchedSuggestion::new(suggestion, match_type)
})
})
.sorted_by(MatchedSuggestion::cmp_by_display)
}
pub fn complete(
matcher: MatchStrategy,
location: &Spanned<LocationType>,
found_signature: Option<SignatureAtTokenIndex>,
) -> Vec<MatchedSuggestion> {
if let Spanned {
item: LocationType::Flag {
flag_name: name, ..
},
..
} = location
{
if let Some(found_signature) = found_signature {
let name = name.as_ref().map(|name| &name.item);
return match name {
// Case 1: if we are completing on '--<partial>', we surface long hand flags that begin with partial
Some(long) if long.starts_with("--") => long_hand_flag_suggestions(
found_signature.signature,
matcher,
&long[2..],
Some(FlagStyle::DoubleDash),
)
.collect(),
// Case 2: if we are completing on a single '-', we surface all short hand flags followed by all long hand flags
Some(short) if short == "-" => {
short_hand_flag_suggestions(found_signature.signature, "")
.chain(long_hand_flag_suggestions(
found_signature.signature,
matcher,
"",
None,
))
.collect()
}
// Case 3: if we are completing on '-<partial>', we surface short hand flags that begin with partial,
// followed by long hand flags that begin with partial.
Some(short) if short.starts_with('-') => {
short_hand_flag_suggestions(found_signature.signature, &short[1..])
.chain(long_hand_flag_suggestions(
found_signature.signature,
matcher,
&short[1..],
Some(FlagStyle::SingleDash),
))
.collect()
}
// Case 4: if we are completing on whitespace (i.e. no prefix), we surface subcommands,
// followed by all long hand flags, followed by all short hand flags
None => long_hand_flag_suggestions(found_signature.signature, matcher, "", None)
.chain(short_hand_flag_suggestions(found_signature.signature, ""))
.collect(),
_ => {
log::info!("Reached option completion branch that should be unreachable");
Default::default()
}
};
}
}
Default::default()
}
@@ -0,0 +1,5 @@
#[cfg_attr(feature = "v2", path = "v2.rs")]
#[cfg_attr(not(feature = "v2"), path = "legacy.rs")]
mod imp;
pub use imp::*;
@@ -0,0 +1,173 @@
//! Contains the v2 implementation of flag suggestion generation that depends on the JS-compatible
//! command signature struct (`crate::signatures::CommandSignature`).
use std::cmp::Ordering;
use std::iter;
use itertools::Itertools;
use crate::{
completer::{
describe::OptionCaseSensitivity, suggest::MatchRequirement, LocationType, Match,
MatchStrategy, MatchedSuggestion, Suggestion, SuggestionType,
},
meta::Spanned,
signatures::Command,
};
pub fn complete(
matcher: MatchStrategy,
location: &Spanned<LocationType>,
found_signature: Option<&Command>,
) -> Vec<MatchedSuggestion> {
let (
Some(found_signature),
Spanned {
item:
LocationType::Flag {
flag_name: partial_flag_name,
..
},
..
},
) = (found_signature, location)
else {
return Default::default();
};
let suggestions = short_hand_flag_suggestions(
found_signature,
partial_flag_name
.as_ref()
.map(|name| name.item.as_str())
.unwrap_or(""),
matcher,
);
let should_order_long_hand_before_short_hand = partial_flag_name.is_none();
let sort_flag_suggestions = |a: &MatchedSuggestion, b: &MatchedSuggestion| -> Ordering {
match (
is_short_hand_flag_name(a.suggestion.display.as_str()),
is_short_hand_flag_name(b.suggestion.display.as_str()),
) {
(true, false) => {
if should_order_long_hand_before_short_hand {
Ordering::Greater
} else {
Ordering::Less
}
}
(false, true) => {
if should_order_long_hand_before_short_hand {
Ordering::Less
} else {
Ordering::Greater
}
}
(_, _) => a.cmp_by_display(b),
}
};
suggestions.sorted_by(sort_flag_suggestions).collect()
}
/// Returns suggestions for short hand flags (i.e. flags that are of the form '-X').
///
/// Short-hand flags included in `input_token` are not included in returned suggestions since
/// they've already been specified, except for the last specified flag (if any), since the
/// completion engine always returns the suggestion for the 'current token' if there is an exact
/// match, (which in that case would be the last short-hand flag in the token).
fn short_hand_flag_suggestions(
command_signature: &Command,
input_token: &str,
matcher: MatchStrategy,
) -> Box<dyn Iterator<Item = MatchedSuggestion>> {
let (prefix, partial_flag_name) =
if let Some(partial_flag_name) = input_token.strip_prefix("--") {
("--", partial_flag_name)
} else if let Some(partial_flag_name) = input_token.strip_prefix('-') {
("-", partial_flag_name)
} else if input_token.is_empty() {
("", "")
} else {
return Box::new(iter::empty());
};
Box::new(
command_signature
.options
.iter()
.flat_map(|option| option.name.iter().map(move |name| (option, name)))
.filter(|(_, name)| name.starts_with(prefix))
.filter_map(|(option, name)| {
if is_short_hand_flag_name(name) {
// Multiple short-hand flags can be specified in a single token with a leading
// '-' (e.g. ssh -Xv). Do not suggest a shorthand flag if it has already been
// specified unless it is the last flag in the current token; the completion engine
// always returns a suggestion if it exactly matches the current token.
let name_without_prefix = name.trim_start_matches('-');
let is_flag_already_included = partial_flag_name.contains(name_without_prefix);
let is_current_flag = partial_flag_name
.chars()
.last()
.is_some_and(|c| c.to_string() == name_without_prefix);
(!is_flag_already_included || is_current_flag).then(|| {
let replacement_text = if is_current_flag {
// The replacement text should be exactly the existing flags.
format!("-{partial_flag_name}")
} else {
// The replacement text should be the existing flags followed by the new one.
format!("-{partial_flag_name}{name_without_prefix}")
};
MatchedSuggestion {
suggestion: Suggestion::new(
name,
replacement_text,
option.description.clone(),
// TODO(CORE-2795)
SuggestionType::Option(
MatchRequirement::EntireName,
OptionCaseSensitivity::CaseSensitive,
),
option.priority.into(),
),
match_type: Match::Prefix {
is_case_sensitive: true,
},
}
})
} else if is_long_hand_flag_name(name) {
matcher.get_match_type(input_token, name).map(|match_type| {
let suggestion = Suggestion::with_same_display_and_replacement(
name,
option.description.clone(),
// TODO(CORE-2795)
SuggestionType::Option(
MatchRequirement::EntireName,
OptionCaseSensitivity::CaseSensitive,
),
option.priority.into(),
);
MatchedSuggestion::new(suggestion, match_type)
})
} else {
None
}
})
.sorted_by(MatchedSuggestion::cmp_by_display),
)
}
/// Heuristic to determine if a flag name is a short-hand flag or not.
///
/// * A single dash followed by a single character (`-h`, `-v`, etc.) is short-hand, unless the second character is also a dash.
/// * A single dash followed by multiple characters (`-version`) is long-hand
/// * Two dashes followed by 0 or more characters is long-hand
fn is_short_hand_flag_name(name: &str) -> bool {
name.len() == 2 && name.starts_with('-') && name != "--"
}
/// Tests if `name` is a long-hand flag name. A long-hand flag is a string
/// starting with `-` that is not a short-hand flag.
fn is_long_hand_flag_name(name: &str) -> bool {
name.starts_with('-') && !is_short_hand_flag_name(name)
}
@@ -0,0 +1,18 @@
use crate::{completer::TopLevelCommandCaseSensitivity, signatures::CommandRegistry};
/// Returns the name of the argument that should be given at `idx` for the given command.
pub(super) fn argument_name_at_index_for_command(
command: &str,
idx: usize,
command_registry: &CommandRegistry,
command_case_sensitivity: TopLevelCommandCaseSensitivity,
) -> Option<String> {
command_registry
.signature_from_line(command, command_case_sensitivity)
.and_then(|found_signature| {
let arguments = found_signature.signature.arguments();
arguments
.get(idx)
.map(|arg| arg.name().unwrap_or_default().to_string())
})
}
@@ -0,0 +1,497 @@
mod argument;
mod command;
mod flag;
pub(crate) mod path;
mod variable;
pub use argument::complete as argument_suggestions;
pub use command::complete as command_suggestions;
pub use flag::complete as flag_suggestions;
pub use path::{EngineDirEntry, EngineFileType};
pub use variable::suggestions as variable_suggestions;
cfg_if::cfg_if! {
if #[cfg(feature = "v2")] {
mod v2;
use v2::argument_name_at_index_for_command;
} else {
mod legacy;
use legacy::argument_name_at_index_for_command;
}
}
use crate::{
completer::{CompletionContext, TopLevelCommandCaseSensitivity},
meta::{HasSpan, Span, Spanned, SpannedItem},
parsers::{
hir::{Command, Expression, ExternalCommand, FlagType, ShellCommand},
ArgumentError, ClassifiedCommand, ParseError, ParseErrorReason, ParsedExpression,
ParsedToken,
},
signatures::CommandRegistry,
};
pub type CompletionLocation = Spanned<LocationType>;
#[derive(Debug, Clone, Eq, PartialEq)]
pub enum LocationType {
Command {
/// Whether the command is in the registry.
is_recognized: bool,
parsed_token: ParsedToken,
},
Flag {
/// The name of the command.
command_name: Spanned<String>,
/// The name of the flag. This will be `None` if no part of the flag was provided. For
/// example, `git ` would provide a `Flag` location type with an empty `flag_name`.
flag_name: Option<Spanned<String>>,
},
Argument {
/// The name of the command.
command_name: Spanned<String>,
/// The name of the argument that has been typed so far. This will be `None` if no part of
/// the argument was provided, such as `cd `.
argument_name: Option<String>,
parsed_token: ParsedToken,
},
Variable {
parsed_token: ParsedToken,
},
}
impl LocationType {
pub fn is_command(&self) -> bool {
matches!(self, LocationType::Command { .. })
}
}
pub struct Flatten<'s> {
line: &'s str,
context: &'s CommandRegistry,
error: Option<&'s ParseError>,
command: Spanned<String>,
flag: Option<String>,
}
impl<'s> Flatten<'s> {
/// Converts a spanned `Expression` into a completion location for use in `WarpCompleter`.
fn expression(&self, e: &Spanned<ParsedExpression>) -> Vec<CompletionLocation> {
match e.item.expression() {
Expression::Command => {
vec![LocationType::Command {
is_recognized: true,
parsed_token: e.item.value().to_owned(),
}
.spanned(e.span)]
}
Expression::Literal => {
vec![LocationType::Argument {
command_name: self.command.clone(),
argument_name: self.flag.clone(),
parsed_token: e.item.value().clone(),
}
.spanned(e.span)]
}
Expression::ValidatableArgument(_) => {
vec![LocationType::Argument {
command_name: self.command.clone(),
argument_name: self.flag.clone(),
parsed_token: e.item.value().to_owned(),
}
.spanned(e.span)]
}
Expression::Unknown => Vec::new(),
Expression::Variable => vec![LocationType::Variable {
parsed_token: e.item.value().to_owned(),
}
.spanned(e.span)],
}
}
fn unclassified_command(
&self,
command: &ExternalCommand,
command_case_sensitivity: TopLevelCommandCaseSensitivity,
) -> Vec<CompletionLocation> {
let capacity = 1
+ command
.args
.flags
.as_ref()
.map_or(0, |flags| flags.flags.len())
+ command
.args
.positionals
.as_ref()
.map_or(0, |positionals| positionals.len());
let mut result = Vec::with_capacity(capacity);
match command.args.command_name.item.expression() {
Expression::Command | Expression::Literal => result.push(
LocationType::Command {
is_recognized: false,
parsed_token: command.name.clone(),
}
.spanned(command.name_span),
),
_ => (),
}
if let Some(flags) = &command.args.flags {
for flag in flags.iter() {
match &flag.flag_type {
FlagType::NoArgument => {
result.push(
LocationType::Flag {
command_name: command
.name
.as_str()
.to_owned()
.spanned(command.name_span),
flag_name: None,
}
.spanned(flag.name_span),
);
}
FlagType::Argument { value } => {
result.push(
LocationType::Flag {
command_name: command
.name
.as_str()
.to_owned()
.spanned(command.name_span),
flag_name: None,
}
.spanned(flag.name_span),
);
result.append(&mut self.with_flag(flag.name.clone()).expression(value));
}
}
}
}
if let Some(positionals) = &command.args.positionals {
let positionals = positionals.iter();
result.extend(positionals.enumerate().flat_map(|(idx, positional)| {
match positional.item.expression() {
Expression::Unknown => {
let unknown = positional.span.slice(self.line);
let location = if unknown.starts_with('-') {
LocationType::Flag {
command_name: command
.name
.as_str()
.to_owned()
.spanned(command.name_span),
flag_name: Some(unknown.to_string().spanned(positional.span)),
}
} else {
LocationType::Argument {
command_name: command
.name
.as_str()
.to_owned()
.spanned(command.name_span),
argument_name: argument_name_at_index_for_command(
command.name_span.slice(self.line),
idx,
self.context,
command_case_sensitivity,
),
parsed_token: positional.item.value().clone(),
}
};
vec![location.spanned(positional.span)]
}
_ => self.expression(positional),
}
}));
}
result
}
fn command(
&self,
command: &ShellCommand,
command_case_sensitivity: TopLevelCommandCaseSensitivity,
) -> Vec<CompletionLocation> {
let capacity = 1
+ command
.args
.flags
.as_ref()
.map_or(0, |flags| flags.flags.len())
+ command
.args
.positionals
.as_ref()
.map_or(0, |positionals| positionals.len());
let mut result = Vec::with_capacity(capacity);
let parsed_expression = &command.args.command_name.item;
match parsed_expression.expression() {
Expression::Command => {
result.push(
LocationType::Command {
is_recognized: true,
parsed_token: parsed_expression.value().to_owned(),
}
.spanned(command.name_span),
);
}
Expression::Literal => {
result.push(
LocationType::Command {
is_recognized: false,
parsed_token: parsed_expression.value().to_owned(),
}
.spanned(command.name_span),
);
}
_ => (),
}
if let Some(positionals) = &command.args.positionals {
let positionals = positionals.iter();
result.extend(positionals.enumerate().flat_map(|(idx, positional)| {
match positional.item.expression() {
Expression::Unknown => {
let unknown = positional.span.slice(self.line);
let location = if unknown.starts_with('-') {
LocationType::Flag {
command_name: command.name.clone().spanned(command.name_span),
flag_name: Some(unknown.to_string().spanned(positional.span)),
}
} else {
LocationType::Argument {
command_name: command.name.clone().spanned(command.name_span),
argument_name: argument_name_at_index_for_command(
command.name_span.slice(self.line),
idx,
self.context,
command_case_sensitivity,
),
parsed_token: positional.item.value().clone(),
}
};
vec![location.spanned(positional.span)]
}
_ => self.expression(positional),
}
}));
}
if let Some(flags) = &command.args.flags {
for flag in flags.iter() {
result.push(
LocationType::Flag {
command_name: command.name.clone().spanned(command.name_span),
flag_name: Some(
flag.name_span
.slice(self.line)
.to_string()
.spanned(flag.name_span),
),
}
.spanned(flag.name_span),
);
if let FlagType::Argument { value } = &flag.flag_type {
result.append(&mut self.with_flag(flag.name.clone()).expression(value));
}
}
}
// Flags will not appear in [`crate::parsers::hir::CommandCallInfo::flags`] if they require
// an argument but it's missing. In that case, it is a parse error. We still want known
// flags which are missing an argument to be treated as a possible completion location. For
// example, the cursor may be at the end of `npx --shell`, where `--shell` requires an
// argument, and may actually want to complete the option `--shell-auto-fallback` instead.
// So, check for this case in the parse error.
if let Some(ParseError {
reason:
ParseErrorReason::ArgumentError {
error: ArgumentError::MissingValueForName { name, .. },
..
},
}) = self.error
{
result.push(
LocationType::Flag {
command_name: command.name.clone().spanned(command.name_span),
flag_name: Some(name.clone()),
}
.spanned(name.span()),
)
}
result
}
/// Flattens the block into a Vec of completion locations
pub fn completion_locations(
&self,
command: &Command,
command_case_sensitivity: TopLevelCommandCaseSensitivity,
) -> Vec<CompletionLocation> {
match command {
Command::Classified(cmd) => self.command(cmd, command_case_sensitivity),
Command::Unclassified(cmd) => self.unclassified_command(cmd, command_case_sensitivity),
}
}
pub fn new(
line: &'s str,
context: &'s CommandRegistry,
command: Spanned<String>,
error: Option<&'s ParseError>,
) -> Flatten<'s> {
Flatten {
line,
context,
error,
command,
flag: None,
}
}
pub fn with_flag(&self, flag: String) -> Flatten<'s> {
Flatten {
line: self.line,
context: self.context,
error: self.error,
command: self.command.clone(),
flag: Some(flag),
}
}
}
/// Characters that precede a command name
const BEFORE_COMMAND_CHARS: &[char] = &['|', '(', ';'];
/// Determines the completion location for a given block at the given cursor position
pub fn completion_location(
ctx: &dyn CompletionContext,
line: &str,
classified_command: Option<&ClassifiedCommand>,
) -> Vec<CompletionLocation> {
let (command, error) = match classified_command {
Some(command_with_error) => (&command_with_error.command, &command_with_error.error),
// If there's no command--treat the completion location as a single foreign command to
// surface top level commands.
None => {
return vec![LocationType::Command {
is_recognized: false,
parsed_token: ParsedToken::empty(),
}
.spanned(Span::default())]
}
};
let completion_engine = Flatten::new(
line,
ctx.command_registry(),
command.command_name_span().map(ToOwned::to_owned),
error.as_ref(),
);
let locations = completion_engine.completion_locations(command, ctx.command_case_sensitivity());
if locations.is_empty() {
vec![LocationType::Command {
is_recognized: false,
parsed_token: ParsedToken::empty(),
}
.spanned(Span::default())]
} else {
let mut command = None;
let mut prev = None;
for loc in locations {
// We don't use span.contains because we want to include the end. This handles the case
// where the cursor is just after the text (i.e., no space between cursor and text)
if loc.span.start() <= line.len() && line.len() <= loc.span.end() {
// The parser sees the "-" in `cmd -` as an argument, but the user is likely
// expecting a flag.
return match loc.item {
LocationType::Argument {
command_name: ref cmd,
..
} => {
let cmd = cmd.clone();
if loc.span.slice(line) == "-" {
let span = loc.span;
return vec![
loc,
LocationType::Flag {
command_name: cmd,
flag_name: Some("-".to_owned().spanned(span)),
}
.spanned(span),
];
}
// This ensures that flags are not included if the user has already typed a
// non "-" such as "git c<tab>"
vec![loc]
}
_ => vec![loc],
};
} else if line.len() < loc.span.start() {
break;
}
if loc.item.is_command() {
command = Some(String::from(loc.span.slice(line)).spanned(loc.span));
}
prev = Some(loc);
}
if let Some(prev) = prev {
// Cursor is between locations (or at the end). Look at the line to see if the cursor
// is after some character that would imply we're in the command position.
let start = prev.span.end();
if line[start..].contains(BEFORE_COMMAND_CHARS) {
vec![LocationType::Command {
is_recognized: true,
parsed_token: ParsedToken::empty(),
}
.spanned(Span::new(line.len(), line.len()))]
} else if let Some(command) = command {
let arg_location = LocationType::Argument {
command_name: command.clone(),
argument_name: None,
parsed_token: ParsedToken::empty(),
}
.spanned(Span::new(line.len(), line.len()));
let flag_location = LocationType::Flag {
command_name: command,
flag_name: None,
}
.spanned(Span::new(line.len(), line.len()));
vec![arg_location, flag_location]
} else {
vec![]
}
} else {
// Cursor is before any possible completion location, so must be a command
vec![LocationType::Command {
is_recognized: true,
parsed_token: ParsedToken::empty(),
}
.spanned(Span::new(line.len(), line.len()))]
}
}
}
#[cfg(not(feature = "v2"))]
#[cfg(test)]
#[path = "test.rs"]
mod tests;
@@ -0,0 +1,311 @@
use std::fmt::{Display, Formatter};
use std::fs::DirEntry;
use itertools::{iproduct, Itertools};
use lazy_static::lazy_static;
use serde::{Deserialize, Serialize};
use typed_path::{TypedPath, TypedPathBuf};
use warp_command_signatures::{IconType, PathSuggestionType};
use galaxy_util::path::HOME_DIR_ENV_VAR_PREFIX;
use crate::completer::suggest::Priority;
use crate::completer::{
context::PathCompletionContext,
matchers::MatchStrategy,
suggest::{MatchedSuggestion, Suggestion, SuggestionType},
};
use crate::parsers::ParsedToken;
/// TODO(CORE-3074): This only applies to Unix.
const ROOT_DIR_STR: &str = "/";
lazy_static! {
pub static ref CURR_DIRECTORY_ENTRY: EngineDirEntry = EngineDirEntry {
file_name: ".".to_owned(),
file_type: EngineFileType::Directory,
};
pub static ref PARENT_DIRECTORY_ENTRY: EngineDirEntry = EngineDirEntry {
file_name: "..".to_owned(),
file_type: EngineFileType::Directory,
};
}
/// A `DirEntry` for the completions engine that abstracts whether the contents
/// come from a remote or local filesystem.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Hash)]
pub struct EngineDirEntry {
pub file_name: String,
pub file_type: EngineFileType,
}
impl EngineDirEntry {
pub fn is_dir(&self) -> bool {
self.file_type == EngineFileType::Directory
}
pub fn file_name(&self) -> &str {
self.file_name.as_str()
}
pub fn is_hidden(&self) -> bool {
self.file_name.starts_with('.')
}
}
impl TryFrom<DirEntry> for EngineDirEntry {
type Error = std::io::Error;
fn try_from(value: DirEntry) -> Result<Self, Self::Error> {
let file_type = value.file_type()?;
let is_dir = if file_type.is_dir() {
true
} else if file_type.is_symlink() {
// If the file is a symlink, follow the symlink and check if the target is a directory.
value
.path()
.metadata()
.map(|metadata| metadata.is_dir())
.unwrap_or(false)
} else {
false
};
let file_type = if is_dir {
EngineFileType::Directory
} else {
EngineFileType::File
};
Ok(Self {
file_name: value.file_name().to_string_lossy().to_string(),
file_type,
})
}
}
#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize, Hash)]
pub enum EngineFileType {
Directory,
File,
}
impl Display for EngineFileType {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{}",
match self {
EngineFileType::Directory => "Directory",
EngineFileType::File => "File",
}
)
}
}
impl From<EngineFileType> for PathSuggestionType {
fn from(path_type: EngineFileType) -> Self {
match path_type {
EngineFileType::Directory => Self::Folder,
EngineFileType::File => Self::File,
}
}
}
/// Returns the sorted directories relative to the provided path and filter.
///
/// Note we are returning a Vector instead of iterator here because Rust currently doesn't support
/// returning opaque types (impl) in traits. This should have minimum impact on the memory allocation
/// since we are already calling `sort_by` before collecting which allocates memory.
pub(crate) async fn sorted_directories_relative_to(
path: &ParsedToken,
matcher: MatchStrategy,
ctx: &dyn PathCompletionContext,
) -> Vec<MatchedSuggestion> {
list_directory_contents(path, matcher, ctx)
.await
.into_iter()
.filter(|path_suggestion| {
path_suggestion
.suggestion
.file_type
.is_some_and(|file_type| file_type == EngineFileType::Directory)
})
.sorted_by(|suggestion_a, suggestion_b| {
suggestion_a
.suggestion
.cmp_by_display(&suggestion_b.suggestion)
})
.collect()
}
pub async fn sorted_paths_relative_to(
path: &ParsedToken,
matcher: MatchStrategy,
ctx: &dyn PathCompletionContext,
) -> Vec<MatchedSuggestion> {
list_directory_contents(path, matcher, ctx)
.await
.into_iter()
.sorted_by(|suggestion_a, suggestion_b| {
suggestion_a
.suggestion
.cmp_by_display(&suggestion_b.suggestion)
})
.collect()
}
/// Lists all directory contents within the directory identified by the parent directory of
/// `relative_to`.
/// If `relative_to` is `foo/bar/`, directory contents beanth `bar/` will be returned.
/// If `relative_to` is `foo/bar/a`, directory contents relative to `/bar` are returned, while
/// ensuring they match the trailing `a`.
/// `relative_to` can contain backslash escaped tildes so we can distinguish between tildes that
/// should be expanded into the home directory and a literal tilde.
/// NOTE: The resulting suggestion replacements are shell-escaped; display values are unescaped.
async fn list_directory_contents(
relative_to: &ParsedToken,
matcher: MatchStrategy,
ctx: &dyn PathCompletionContext,
) -> Vec<MatchedSuggestion> {
let home_dir = ctx.home_directory();
let path_separators = ctx.path_separators();
let split_path = SplitPath::new(
ctx.pwd(),
relative_to.as_str(),
home_dir,
path_separators.all,
);
let dir_entries = ctx
.list_directory_entries(split_path.directory_absolute_path.clone())
.await;
let root_dir_entry =
(split_path.directory_absolute_path.to_str() == Some(ROOT_DIR_STR)).then(|| {
EngineDirEntry {
file_name: ROOT_DIR_STR.to_owned(),
file_type: EngineFileType::Directory,
}
});
dir_entries
.iter()
.chain(root_dir_entry.iter())
.chain([&*CURR_DIRECTORY_ENTRY, &*PARENT_DIRECTORY_ENTRY])
.filter_map(move |entry| {
let mut file_name = entry.file_name().to_string();
let match_type = matcher.get_match_type(&split_path.file_name, file_name.as_str())?;
let path = if entry.file_name() == ROOT_DIR_STR {
ROOT_DIR_STR.to_owned()
} else {
if entry.is_dir() {
file_name.push(path_separators.main);
}
// We use `shell_escape()` instead of `escape()` on the relative path name to allow
// home directory expansion if needed.
format!(
"{}{}",
if split_path.directory_relative_path_name.is_empty() {
"".to_owned()
} else {
// `directory_relative_path_name` may have escaped tildes which we use to
// distinguish between a tilde representing the home directory and a literal
// tilde. `shell_escape()` will doubly escape an escaped tilde which is
// incorrect so we correct that behavior here.
ctx.shell_family()
.shell_escape(split_path.directory_relative_path_name.as_str())
.replace(r"\\\~", r"\~")
},
// Home directory expansion is never needed on file names, so we use the
// standard `escape()`.
ctx.shell_family().escape(file_name.as_str())
)
};
(!entry.is_hidden() || split_path.file_name.starts_with('.')).then(|| {
let mut suggestion = Suggestion::new(
file_name.as_str(),
path,
Some(entry.file_type.to_string()),
SuggestionType::Argument,
Priority::default(),
);
suggestion.file_type = Some(entry.file_type);
suggestion.override_icon = Some(match entry.file_type {
EngineFileType::File => IconType::File,
EngineFileType::Directory => IconType::Folder,
});
MatchedSuggestion {
suggestion,
match_type,
}
})
})
.collect_vec()
}
/// A path split into the parent path (the entire piece before the last separator) and the
/// file_name (the piece after the last separator).
#[derive(Debug, PartialEq, Eq)]
struct SplitPath {
/// The absolute path to the directory containing the file named `file_name`.
directory_absolute_path: TypedPathBuf,
/// The path to the directory containing the file named `file_name`, relative to the current
/// working directory. This is may contain unexpanded `~` or `$HOME`.
directory_relative_path_name: String,
/// The name of the `file`.
file_name: String,
}
impl SplitPath {
/// Returns a `SplitPath` based on the given path values.
///
/// `current_directory` is the directory to which `relative_path` is relative.
/// `relative_path` may contain '~' or '$HOME'. If `relative_path` begins with one of those
/// strings, we expand that part of the path to the given `home_directory` value, if it is
/// `Some()`. Note that `relative_path` comes directly from a user-specified path token. This
/// may contain escaped tildes (for example if the user is completing on a path that contains
/// literal tildes), which need to be unescaped before using the path to generate path
/// suggestions.
fn new(
current_directory: TypedPath,
relative_path: &str,
home_directory: Option<&str>,
path_separators: &[char],
) -> Self {
let (directory_relative_path_name, file_name) = match relative_path.rfind(path_separators) {
Some(pos) => relative_path.split_at(pos + 1),
None => ("", relative_path),
};
let directory_absolute_path = if directory_relative_path_name.is_empty() {
current_directory.to_path_buf()
} else if let Some(rest) = iproduct!([HOME_DIR_ENV_VAR_PREFIX, "~"], path_separators)
.find_map(|(prefix, sep)| {
directory_relative_path_name.strip_prefix(&format!("{prefix}{sep}"))
})
{
let mut home_directory = TypedPathBuf::from(home_directory.unwrap_or_default());
home_directory.push(rest.replace(r"\~", "~"));
home_directory
} else {
current_directory.join(directory_relative_path_name.replace(r"\~", "~"))
};
// Unescape escaped tildes in the filename.
let file_name = file_name.replace(r"\~", "~");
SplitPath {
directory_absolute_path,
directory_relative_path_name: directory_relative_path_name.to_owned(),
file_name,
}
}
}
#[cfg(test)]
#[path = "path_test.rs"]
mod tests;
@@ -0,0 +1,474 @@
use warp_command_signatures::IconType;
use crate::completer::testing::MockPathCompletionContext;
use super::*;
#[cfg(windows)]
mod windows_constants {
pub(super) const TEST_HOME_DIR: &str = r"C:\Users\test";
}
#[cfg(windows)]
use windows_constants::*;
#[cfg(unix)]
mod unix_constants {
pub(super) const TEST_HOME_DIR: &str = "/users/test";
}
#[cfg(unix)]
use unix_constants::*;
#[test]
fn test_split_path() {
let path = TypedPathBuf::from_unix("/Users/warpuser");
let split_path = SplitPath::new(
path.to_path(),
"~/Warp.app",
Some("/Users/warpuser"),
&['/'],
);
assert_eq!(
split_path,
SplitPath {
directory_absolute_path: path.clone(),
directory_relative_path_name: "~/".to_owned(),
file_name: "Warp.app".to_owned()
}
);
let split_path = SplitPath::new(
path.to_path(),
"Warp.app/Contents",
Some("/Users/warpuser"),
&['/'],
);
assert_eq!(
split_path,
SplitPath {
directory_absolute_path: TypedPathBuf::from("/Users/warpuser/Warp.app/"),
directory_relative_path_name: "Warp.app/".to_owned(),
file_name: "Contents".to_owned()
}
);
let split_path = SplitPath::new(
path.to_path(),
"Warp.app/macOS/bin/warp.o",
Some("/Users/warpuser"),
&['/'],
);
assert_eq!(
split_path,
SplitPath {
directory_absolute_path: TypedPathBuf::from("/Users/warpuser/Warp.app/macOS/bin/"),
directory_relative_path_name: "Warp.app/macOS/bin/".to_owned(),
file_name: "warp.o".to_owned()
}
);
}
fn file_entry(file_name: &str) -> EngineDirEntry {
EngineDirEntry {
file_name: file_name.to_owned(),
file_type: EngineFileType::File,
}
}
fn dir_entry(file_name: &str) -> EngineDirEntry {
EngineDirEntry {
file_name: file_name.to_owned(),
file_type: EngineFileType::Directory,
}
}
#[cfg_attr(
windows,
ignore = "CORE-3696: path sorting comparison function needs separators"
)]
#[test]
pub fn test_sorted_paths_relative_to() {
let ctx = MockPathCompletionContext::default().with_entries_in_pwd([
file_entry("Cargo.toml"),
dir_entry("src"),
dir_entry("target"),
dir_entry(".hidden"),
]);
assert_eq!(
galaxyui::r#async::block_on(sorted_paths_relative_to(
&ParsedToken::empty(),
MatchStrategy::CaseInsensitive,
&ctx
))
.into_iter()
.map(|matched_suggestion| matched_suggestion.suggestion)
.collect_vec(),
vec![
Suggestion::with_same_display_and_replacement(
"Cargo.toml",
Some("File".into()),
SuggestionType::Argument,
Priority::default(),
)
.with_icon_override(IconType::File)
.with_file_type(EngineFileType::File),
Suggestion::with_same_display_and_replacement(
"src/",
Some("Directory".into()),
SuggestionType::Argument,
Priority::default(),
)
.with_icon_override(IconType::Folder)
.with_file_type(EngineFileType::Directory),
Suggestion::with_same_display_and_replacement(
"target/",
Some("Directory".into()),
SuggestionType::Argument,
Priority::default(),
)
.with_icon_override(IconType::Folder)
.with_file_type(EngineFileType::Directory),
]
);
assert_eq!(
galaxyui::r#async::block_on(sorted_paths_relative_to(
&ParsedToken::new("sr"),
MatchStrategy::CaseInsensitive,
&ctx
))
.into_iter()
.map(|matched_suggestion| matched_suggestion.suggestion)
.collect_vec(),
vec![Suggestion::with_same_display_and_replacement(
"src/",
Some("Directory".into()),
SuggestionType::Argument,
Priority::default(),
)
.with_icon_override(IconType::Folder)
.with_file_type(EngineFileType::Directory)]
);
assert_eq!(
galaxyui::r#async::block_on(sorted_paths_relative_to(
&ParsedToken::new("."),
MatchStrategy::CaseInsensitive,
&ctx
))
.into_iter()
.map(|matched_suggestion| matched_suggestion.suggestion)
.collect_vec(),
vec![
Suggestion::with_same_display_and_replacement(
"./",
Some("Directory".into()),
SuggestionType::Argument,
Priority::default(),
)
.with_icon_override(IconType::Folder)
.with_file_type(EngineFileType::Directory),
Suggestion::with_same_display_and_replacement(
"../",
Some("Directory".into()),
SuggestionType::Argument,
Priority::default(),
)
.with_icon_override(IconType::Folder)
.with_file_type(EngineFileType::Directory),
Suggestion::with_same_display_and_replacement(
".hidden/",
Some("Directory".into()),
SuggestionType::Argument,
Priority::default(),
)
.with_icon_override(IconType::Folder)
.with_file_type(EngineFileType::Directory),
]
);
}
#[test]
pub fn test_sorted_directories_relative_to() {
let ctx = MockPathCompletionContext::default().with_entries_in_pwd([
file_entry("Cargo.toml"),
dir_entry("src"),
dir_entry("target"),
dir_entry(".hidden"),
]);
assert_eq!(
galaxyui::r#async::block_on(sorted_directories_relative_to(
&ParsedToken::empty(),
MatchStrategy::CaseInsensitive,
&ctx
))
.into_iter()
.map(|matched_suggestion| matched_suggestion.suggestion)
.collect_vec(),
vec![
Suggestion::with_same_display_and_replacement(
"src/",
Some("Directory".into()),
SuggestionType::Argument,
Priority::default(),
)
.with_icon_override(IconType::Folder)
.with_file_type(EngineFileType::Directory),
Suggestion::with_same_display_and_replacement(
"target/",
Some("Directory".into()),
SuggestionType::Argument,
Priority::default(),
)
.with_icon_override(IconType::Folder)
.with_file_type(EngineFileType::Directory),
]
);
assert_eq!(
galaxyui::r#async::block_on(sorted_directories_relative_to(
&ParsedToken::new("s"),
MatchStrategy::CaseInsensitive,
&ctx
))
.into_iter()
.map(|matched_suggestion| matched_suggestion.suggestion)
.collect_vec(),
vec![Suggestion::with_same_display_and_replacement(
"src/",
Some("Directory".into()),
SuggestionType::Argument,
Priority::default(),
)
.with_icon_override(IconType::Folder)
.with_file_type(EngineFileType::Directory)]
);
}
/// Verify that path suggestions are sorted case-insensitively so that uppercase entries
/// don't always appear before lowercase ones.
#[cfg_attr(
windows,
ignore = "CORE-3696: path sorting comparison function needs separators"
)]
#[test]
pub fn test_sorted_paths_case_insensitive_ordering() {
let ctx = MockPathCompletionContext::default().with_entries_in_pwd([
file_entry("Zebra.txt"),
file_entry("apple.txt"),
dir_entry("Banana"),
file_entry("cherry.txt"),
]);
let suggestions: Vec<String> = galaxyui::r#async::block_on(sorted_paths_relative_to(
&ParsedToken::empty(),
MatchStrategy::CaseInsensitive,
&ctx,
))
.into_iter()
.map(|matched_suggestion| matched_suggestion.suggestion.display.to_string())
.collect();
// Expected case-insensitive order: apple, Banana, cherry, Zebra
assert_eq!(
suggestions,
vec!["apple.txt", "Banana/", "cherry.txt", "Zebra.txt"]
);
}
fn mock_path_completion_ctx_special_characters() -> MockPathCompletionContext {
MockPathCompletionContext::default()
.with_home_directory(TEST_HOME_DIR.to_owned())
.with_entries_in_pwd([dir_entry("!nice ~"), dir_entry("~"), dir_entry("~foo")])
}
/// Check that special characters are properly escaped in the Suggestion.
#[test]
pub fn test_path_completions_with_special_characters_relative_to_cwd() {
let ctx = mock_path_completion_ctx_special_characters();
assert_eq!(
galaxyui::r#async::block_on(sorted_directories_relative_to(
&ParsedToken::empty(),
MatchStrategy::CaseInsensitive,
&ctx
))
.into_iter()
.map(|matched_suggestion| matched_suggestion.suggestion)
.collect_vec(),
vec![
Suggestion::new(
"!nice ~/",
r"\!nice\ \~/",
Some("Directory".into()),
SuggestionType::Argument,
Priority::default(),
)
.with_icon_override(IconType::Folder)
.with_file_type(EngineFileType::Directory),
Suggestion::new(
"~/",
r"\~/",
Some("Directory".into()),
SuggestionType::Argument,
Priority::default(),
)
.with_icon_override(IconType::Folder)
.with_file_type(EngineFileType::Directory),
Suggestion::new(
"~foo/",
r"\~foo/",
Some("Directory".into()),
SuggestionType::Argument,
Priority::default(),
)
.with_icon_override(IconType::Folder)
.with_file_type(EngineFileType::Directory),
]
);
}
/// Check that we can match on special characters at the beginning of the file name.
#[test]
pub fn test_path_completions_with_special_character_case_insensitive() {
let ctx = mock_path_completion_ctx_special_characters();
assert_eq!(
galaxyui::r#async::block_on(sorted_directories_relative_to(
&ParsedToken::new("~"),
MatchStrategy::CaseInsensitive,
&ctx
))
.into_iter()
.map(|matched_suggestion| matched_suggestion.suggestion)
.collect_vec(),
vec![
Suggestion::new(
"~/",
r"\~/",
Some("Directory".into()),
SuggestionType::Argument,
Priority::default(),
)
.with_icon_override(IconType::Folder)
.with_file_type(EngineFileType::Directory),
Suggestion::new(
"~foo/",
r"\~foo/",
Some("Directory".into()),
SuggestionType::Argument,
Priority::default(),
)
.with_icon_override(IconType::Folder)
.with_file_type(EngineFileType::Directory),
]
);
}
/// Check that we can match on special characters regardless of their position in the file name.
#[test]
pub fn test_path_completions_with_special_characters_fuzzy() {
let ctx = mock_path_completion_ctx_special_characters();
assert_eq!(
galaxyui::r#async::block_on(sorted_directories_relative_to(
&ParsedToken::new("~"),
MatchStrategy::Fuzzy,
&ctx
))
.into_iter()
.map(|matched_suggestion| matched_suggestion.suggestion)
.collect_vec(),
vec![
Suggestion::new(
"!nice ~/",
r"\!nice\ \~/",
Some("Directory".into()),
SuggestionType::Argument,
Priority::default(),
)
.with_icon_override(IconType::Folder)
.with_file_type(EngineFileType::Directory),
Suggestion::new(
"~/",
r"\~/",
Some("Directory".into()),
SuggestionType::Argument,
Priority::default(),
)
.with_icon_override(IconType::Folder)
.with_file_type(EngineFileType::Directory),
Suggestion::new(
"~foo/",
r"\~foo/",
Some("Directory".into()),
SuggestionType::Argument,
Priority::default(),
)
.with_icon_override(IconType::Folder)
.with_file_type(EngineFileType::Directory),
]
);
}
fn mock_path_completion_ctx_special_characters_home_dir() -> MockPathCompletionContext {
MockPathCompletionContext::default()
.with_home_directory(TEST_HOME_DIR.to_owned())
.with_entries_in_pwd([dir_entry("~")])
.with_entries(TEST_HOME_DIR.into(), [dir_entry(r"~ testdir")])
}
/// Check that tilde expansion works with path completion and special characters in Suggestions.
#[test]
pub fn test_path_completions_tilde_expansion() {
let ctx = mock_path_completion_ctx_special_characters_home_dir();
assert_eq!(
galaxyui::r#async::block_on(sorted_directories_relative_to(
&ParsedToken::new("~/"),
MatchStrategy::Fuzzy,
&ctx
))
.into_iter()
.map(|matched_suggestion| matched_suggestion.suggestion)
.collect_vec(),
vec![Suggestion::new(
"~ testdir/",
r"~/\~\ testdir/",
Some("Directory".into()),
SuggestionType::Argument,
Priority::default(),
)
.with_icon_override(IconType::Folder)
.with_file_type(EngineFileType::Directory),]
);
}
/// Check that $HOME home directory expansion works with special characters in the suggestions.
#[test]
pub fn test_path_completions_home_env_var_special_characters() {
let ctx = mock_path_completion_ctx_special_characters_home_dir();
assert_eq!(
galaxyui::r#async::block_on(sorted_directories_relative_to(
&ParsedToken::new("$HOME/"),
MatchStrategy::Fuzzy,
&ctx
))
.into_iter()
.map(|matched_suggestion| matched_suggestion.suggestion)
.collect_vec(),
vec![Suggestion::new(
"~ testdir/",
r"$HOME/\~\ testdir/",
Some("Directory".into()),
SuggestionType::Argument,
Priority::default(),
)
.with_icon_override(IconType::Folder)
.with_file_type(EngineFileType::Directory),]
);
}
@@ -0,0 +1,335 @@
use itertools::Itertools;
use galaxy_util::path::EscapeChar;
use super::LocationType;
use crate::completer::testing::FakeCompletionContext;
use crate::completer::CompletionContext;
use crate::meta::{Span, SpannedItem};
use crate::parsers::simple::command_at_cursor_position;
use crate::parsers::ParsedToken;
use crate::parsers::{classify_command, simple::parse_for_completions};
use crate::signatures::testing::{create_test_command_registry, test_signature};
use crate::signatures::CommandRegistry;
use string_offset::ByteOffset;
fn location(line: &str, registry: CommandRegistry, pos: usize) -> Vec<LocationType> {
let ctx = FakeCompletionContext::new(registry);
let line = &line[..pos];
let command_to_complete = parse_for_completions(line, EscapeChar::Backslash, false)
.expect("test command should be able to parse");
let mut tokens = command_to_complete
.parts
.iter()
.map(|s| s.as_str())
.collect_vec();
let classified_command = classify_command(
command_to_complete.clone(),
&mut tokens,
ctx.command_registry(),
ctx.command_case_sensitivity(),
);
crate::completer::engine::completion_location(&ctx, line, classified_command.as_ref())
.into_iter()
.map(|v| v.item)
.collect()
}
#[test]
fn test_command_at_cursor_parse() {
let line = r"git status $(git stash) && git checkout main";
let parse_first_command =
command_at_cursor_position(line, EscapeChar::Backslash, ByteOffset::from(4));
assert!(parse_first_command.is_some());
assert_eq!(
Span::from_list(&parse_first_command.unwrap().parts),
Span::new(0, 23)
);
let parse_second_command =
command_at_cursor_position(line, EscapeChar::Backslash, ByteOffset::from(17));
assert!(parse_second_command.is_some());
assert_eq!(
Span::from_list(&parse_second_command.unwrap().parts),
Span::new(13, 22)
);
let parse_third_command =
command_at_cursor_position(line, EscapeChar::Backslash, ByteOffset::from(28));
assert!(parse_third_command.is_some());
assert_eq!(
Span::from_list(&parse_third_command.unwrap().parts),
Span::new(27, 44)
);
let parse_on_boundary =
command_at_cursor_position(line, EscapeChar::Backslash, ByteOffset::from(25));
assert!(parse_on_boundary.is_none());
let parse_out_of_range =
command_at_cursor_position(line, EscapeChar::Backslash, ByteOffset::from(47));
assert!(parse_out_of_range.is_none());
}
#[test]
fn completes_command_names() {
assert_eq!(
location("cargo", CommandRegistry::default(), 3),
vec![LocationType::Command {
is_recognized: false,
parsed_token: ParsedToken::new("car")
}]
);
assert_eq!(
location("cargo", CommandRegistry::default(), 5),
vec![LocationType::Command {
is_recognized: true,
parsed_token: ParsedToken::new("cargo")
}]
);
assert_eq!(
location("cd path/to | echo 1", CommandRegistry::default(), 13),
vec![LocationType::Command {
is_recognized: true,
parsed_token: ParsedToken::empty()
}]
);
}
#[test]
fn completes_unregistered_command_names() {
assert_eq!(
location("warp", CommandRegistry::empty(), 4),
vec![LocationType::Command {
is_recognized: false,
parsed_token: ParsedToken::new("warp")
}]
);
assert_eq!(
location("echo hola | sdfsd", CommandRegistry::default(), 17),
vec![LocationType::Command {
is_recognized: false,
parsed_token: ParsedToken::new("sdfsd")
}]
);
}
#[test]
fn completes_argument_command() {
let command = "cd ".spanned(Span::new(0, 2));
let cursor_at_whitespace = Span::new(3, 3);
assert_eq!(command.span.slice(command.item), "cd");
assert_eq!(
command.span.until(cursor_at_whitespace).slice(command.item),
"cd "
);
assert_eq!(
location(
command.item,
CommandRegistry::empty(),
cursor_at_whitespace.end()
),
vec![
LocationType::Argument {
command_name: ("cd".to_string().spanned(command.span)),
argument_name: None,
parsed_token: ParsedToken::empty()
},
LocationType::Flag {
command_name: "cd".to_string().spanned(command.span),
flag_name: None
}
]
);
let command = "echo hola | clang ".spanned(Span::new(12, 17));
let cursor_at_whitespace = Span::new(18, 18);
assert_eq!(command.span.slice(command.item), "clang");
assert_eq!(
command.span.until(cursor_at_whitespace).slice(command.item),
"clang "
);
assert_eq!(
location(
command.item,
CommandRegistry::default(),
cursor_at_whitespace.end()
),
vec![
LocationType::Argument {
command_name: ("clang".to_string().spanned(command.span)),
argument_name: None,
parsed_token: ParsedToken::empty(),
},
LocationType::Flag {
command_name: "clang".to_string().spanned(command.span),
flag_name: None
}
]
);
}
#[test]
fn completes_flags_having_one_hyphen() {
assert_eq!(
location("bundle -", CommandRegistry::default(), 8),
vec![LocationType::Flag {
command_name: "bundle".to_owned().spanned(Span::new(0, 6)),
flag_name: Some("-".to_owned().spanned(Span::new(7, 8)))
}]
);
assert_eq!(
location("git add -", CommandRegistry::default(), 9),
vec![
LocationType::Argument {
command_name: ("git add".to_string().spanned(Span::new(0, 7))),
argument_name: None,
parsed_token: ParsedToken::new("-")
},
LocationType::Flag {
command_name: "git add".to_string().spanned(Span::new(0, 7)),
flag_name: Some("-".to_owned().spanned(Span::new(8, 9)))
}
]
);
assert_eq!(
location("echo hola | clang -", CommandRegistry::default(), 19),
vec![
LocationType::Argument {
command_name: ("clang".to_string().spanned(Span::new(12, 17))),
argument_name: None,
parsed_token: ParsedToken::new("-")
},
LocationType::Flag {
command_name: "clang".to_owned().spanned(Span::new(12, 17)),
flag_name: Some("-".to_owned().spanned(Span::new(18, 19)))
},
]
);
}
#[test]
fn completes_flag_argument_after_equal_sign_no_value() {
let cmd = "test".to_string().spanned(Span::new(0, 4));
let registry = create_test_command_registry([test_signature()]);
assert_eq!(
location("test --long=", registry, 12),
vec![LocationType::Argument {
command_name: cmd,
argument_name: Some("--long".to_owned()),
parsed_token: ParsedToken::new(""),
}]
);
}
#[test]
fn completes_flag_argument_after_equal_sign_with_partial_value() {
let cmd = "test".to_string().spanned(Span::new(0, 4));
let registry = create_test_command_registry([test_signature()]);
assert_eq!(
location("test --long=lo", registry, 14),
vec![LocationType::Argument {
command_name: cmd,
argument_name: Some("--long".to_owned()),
parsed_token: ParsedToken::new("lo"),
}]
);
}
#[test]
fn completes_flag_argument_after_equal_sign_with_preceding_switch() {
let cmd = "test".to_string().spanned(Span::new(0, 4));
let registry = create_test_command_registry([test_signature()]);
assert_eq!(
location("test -r --long=", registry, 15),
vec![LocationType::Argument {
command_name: cmd,
argument_name: Some("--long".to_owned()),
parsed_token: ParsedToken::new(""),
}]
);
}
#[test]
fn completes_flag_argument_after_equal_sign_with_multiple_preceding_flags() {
let cmd = "test".to_string().spanned(Span::new(0, 4));
let registry = create_test_command_registry([test_signature()]);
assert_eq!(
location("test --not-long=bar -r --long=", registry, 30),
vec![LocationType::Argument {
command_name: cmd,
argument_name: Some("--long".to_owned()),
parsed_token: ParsedToken::new(""),
}]
);
}
#[test]
fn completes_flag_argument_after_equal_sign_with_preceding_space_delimited_flag() {
let cmd = "test".to_string().spanned(Span::new(0, 4));
let registry = create_test_command_registry([test_signature()]);
assert_eq!(
location("test --not-long bar --long=", registry, 27),
vec![LocationType::Argument {
command_name: cmd,
argument_name: Some("--long".to_owned()),
parsed_token: ParsedToken::new(""),
}]
);
}
#[test]
fn completes_after_completed_equal_sign_flag() {
let cmd = "test".to_string().spanned(Span::new(0, 4));
let registry = create_test_command_registry([test_signature()]);
assert_eq!(
location("test --long=foo ", registry, 16),
vec![
LocationType::Argument {
command_name: cmd.clone(),
argument_name: None,
parsed_token: ParsedToken::empty(),
},
LocationType::Flag {
command_name: cmd,
flag_name: None,
},
]
);
}
#[test]
fn completes_flag_argument_after_equal_sign_with_two_preceding_switches() {
let cmd = "test".to_string().spanned(Span::new(0, 4));
let registry = create_test_command_registry([test_signature()]);
assert_eq!(
location("test -r -V --long=", registry, 18),
vec![LocationType::Argument {
command_name: cmd,
argument_name: Some("--long".to_owned()),
parsed_token: ParsedToken::new(""),
}]
);
}
#[test]
fn completes_flag_argument_with_all_three_flag_styles() {
let cmd = "test".to_string().spanned(Span::new(0, 4));
let registry = create_test_command_registry([test_signature()]);
assert_eq!(
location("test -r --not-long bar --long=", registry, 30),
vec![LocationType::Argument {
command_name: cmd,
argument_name: Some("--long".to_owned()),
parsed_token: ParsedToken::new(""),
}]
);
}
@@ -0,0 +1,20 @@
use crate::{
completer::TopLevelCommandCaseSensitivity,
signatures::{get_matching_signature_for_input, CommandRegistry},
};
/// Returns the name of the argument that should be given at `idx` for the given command.
pub(super) fn argument_name_at_index_for_command(
command: &str,
idx: usize,
command_registry: &CommandRegistry,
// TODO(CORE-2810)
_command_case_sensitivity: TopLevelCommandCaseSensitivity,
) -> Option<String> {
get_matching_signature_for_input(command, command_registry).and_then(|(found_signature, _)| {
found_signature
.arguments
.get(idx)
.map(|arg| arg.name.clone())
})
}
@@ -0,0 +1,43 @@
use std::collections::HashSet;
use crate::completer::{
matchers::MatchStrategy,
suggest::{MatchedSuggestion, Priority, Suggestion, SuggestionType},
};
use crate::parsers::ParsedToken;
use itertools::Itertools;
use smol_str::SmolStr;
pub fn suggestions(
matcher: MatchStrategy,
env_vars: &HashSet<SmolStr>,
parsed_token: &ParsedToken,
) -> Vec<MatchedSuggestion> {
let var_to_complete = parsed_token;
if !var_to_complete.as_str().starts_with('$') {
return Default::default();
}
let var_to_complete = &var_to_complete.as_str()[1..];
env_vars
.iter()
.filter_map(|name| {
matcher
.get_match_type(var_to_complete, name)
.map(|match_type| {
let suggestion_text = format!("${name}");
let suggestion = Suggestion::with_same_display_and_replacement(
suggestion_text,
None,
SuggestionType::Variable,
Priority::default(),
);
MatchedSuggestion::new(suggestion, match_type)
})
})
.sorted_by(MatchedSuggestion::cmp_by_display)
.collect()
}