Initial public release of Warp.
Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
//! This module contains logic to sort the final vector of suggestions based on priority.
|
||||
use std::collections::HashMap;
|
||||
|
||||
use itertools::Itertools;
|
||||
|
||||
use crate::completer::suggest::SuggestionTypeName;
|
||||
|
||||
use super::suggest::{MatchedSuggestion, Priority};
|
||||
|
||||
/// Given a map of computed, unordered suggestion vectors keyed by `SuggestionType`, returns a
|
||||
/// single vector of suggestions in order.
|
||||
/// Suggestions are returned in the following order:
|
||||
/// 1. "High" priority (greater than default) suggestions, by descending priority value using
|
||||
/// lexicographic order to break ties.
|
||||
/// 2. Default priority suggestions, in the same order they were returned by the engine.
|
||||
/// 3. "Low" priority (less than default) suggestions, by descending priority value using
|
||||
/// lexicographic order to break ties.
|
||||
pub(super) fn coalesce_completion_results(
|
||||
completion_results_by_type: HashMap<SuggestionTypeName, Vec<MatchedSuggestion>>,
|
||||
) -> Vec<MatchedSuggestion> {
|
||||
let mut high_priority_suggestions = vec![];
|
||||
let mut default_priority_suggestions = vec![];
|
||||
let mut low_priority_suggestions = vec![];
|
||||
|
||||
// We process suggestions by suggestion type to keep the default priority suggestions ordered by
|
||||
// SuggestionType maintaining the order produced by the completion engine. This isn't necessary
|
||||
// for low and high priority suggestions because those get sorted by priority and lexicogrpahic
|
||||
// order later.
|
||||
for (_, suggestions) in completion_results_by_type
|
||||
.into_iter()
|
||||
.sorted_by_key(|(suggestion_type, _)| *suggestion_type)
|
||||
{
|
||||
let mut default_priority_suggestions_for_type = vec![];
|
||||
|
||||
for suggestion in suggestions.into_iter() {
|
||||
match suggestion.priority().cmp(&Priority::default()) {
|
||||
std::cmp::Ordering::Less => {
|
||||
low_priority_suggestions.push(suggestion);
|
||||
}
|
||||
std::cmp::Ordering::Equal => {
|
||||
default_priority_suggestions_for_type.push(suggestion);
|
||||
}
|
||||
std::cmp::Ordering::Greater => {
|
||||
high_priority_suggestions.push(suggestion);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
default_priority_suggestions.extend(default_priority_suggestions_for_type);
|
||||
}
|
||||
|
||||
let sorted_high_priority_suggestsions = high_priority_suggestions
|
||||
.into_iter()
|
||||
.sorted_by(MatchedSuggestion::cmp_by_reversed_priority_and_display);
|
||||
|
||||
let sorted_low_priority_suggestions = low_priority_suggestions
|
||||
.into_iter()
|
||||
.sorted_by(MatchedSuggestion::cmp_by_reversed_priority_and_display);
|
||||
|
||||
sorted_high_priority_suggestsions
|
||||
.into_iter()
|
||||
// We don't sort default priority suggestions to preserve the ordering produced by the
|
||||
// completion engine.
|
||||
.chain(default_priority_suggestions)
|
||||
.chain(sorted_low_priority_suggestions)
|
||||
.unique_by(|suggestion| suggestion.suggestion.display.clone())
|
||||
.collect::<Vec<MatchedSuggestion>>()
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(feature = "v2")] {
|
||||
mod v2;
|
||||
pub use v2::*;
|
||||
}
|
||||
}
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use smol_str::SmolStr;
|
||||
use typed_path::{TypedPath, TypedPathBuf};
|
||||
use warp_core::command::ExitCode;
|
||||
use warp_util::path::{EscapeChar, ShellFamily};
|
||||
use warpui::platform::OperatingSystem;
|
||||
|
||||
use crate::{completer::TopLevelCommandCaseSensitivity, signatures::CommandRegistry};
|
||||
|
||||
use super::engine::EngineDirEntry;
|
||||
|
||||
/// This trait may be implemented to configure behavior of the completions engine.
|
||||
pub trait CompletionContext: Send + Sync {
|
||||
/// If path completions are supported, should return an instance of a `PathCompletionContext`
|
||||
/// implementation.
|
||||
fn path_completion_context(&self) -> Option<&dyn PathCompletionContext>;
|
||||
|
||||
/// If generators are supported, should return an instance of a `GeneratorContext`
|
||||
/// implementation.
|
||||
fn generator_context(&self) -> Option<&dyn GeneratorContext>;
|
||||
|
||||
fn command_case_sensitivity(&self) -> TopLevelCommandCaseSensitivity {
|
||||
OperatingSystem::get().into()
|
||||
}
|
||||
|
||||
fn alias_and_function_case_sensitivity(&self) -> TopLevelCommandCaseSensitivity {
|
||||
match self.shell_family() {
|
||||
Some(ShellFamily::PowerShell) => TopLevelCommandCaseSensitivity::CaseInsensitive,
|
||||
_ => TopLevelCommandCaseSensitivity::CaseSensitive,
|
||||
}
|
||||
}
|
||||
|
||||
fn escape_char(&self) -> EscapeChar {
|
||||
// This is fallback logic. Ultimately, the escape character depends on the shell, _not_ the
|
||||
// OS. Use the shell to determine this whenever possible. However, if we are in a context
|
||||
// where we don't know/have a running shell, we will go by the default shell per OS.
|
||||
match OperatingSystem::get() {
|
||||
OperatingSystem::Windows => EscapeChar::Backtick,
|
||||
OperatingSystem::Linux | OperatingSystem::Mac | OperatingSystem::Other(_) => {
|
||||
EscapeChar::Backslash
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "v2")]
|
||||
/// If JS execution is supported, should return an instance of `JsExecutionContext`.
|
||||
fn js_context(&self) -> Option<&dyn JsExecutionContext> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Returns top-level commands to be suggested when completing on an empty buffer.
|
||||
fn top_level_commands(&self) -> Box<dyn Iterator<Item = &str> + '_>;
|
||||
|
||||
/// The `CommandRegistry` containing `Signature`s used for completions.
|
||||
fn command_registry(&self) -> &CommandRegistry;
|
||||
|
||||
/// All available environment variables if exists in the current context.
|
||||
fn environment_variable_names(&self) -> Option<&HashSet<SmolStr>> {
|
||||
None
|
||||
}
|
||||
|
||||
/// The active shell configuration if exists in the current context.
|
||||
fn shell_supports_autocd(&self) -> Option<bool> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Returns the command that an alias expands to, if one exists.
|
||||
///
|
||||
/// It's generally incorrect to implement this and not [`CompletionContext::aliases`]
|
||||
fn alias_command(&self, _alias: &str) -> Option<&str> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Returns an iterator over all aliases and their commands.
|
||||
///
|
||||
/// It's generally incorrect to implement this and not [`CompletionContext::alias_command`].
|
||||
fn aliases(&self) -> Box<dyn Iterator<Item = (&str, &str)> + '_> {
|
||||
Box::new(std::iter::empty())
|
||||
}
|
||||
|
||||
/// Returns a map of abbreviations to command.
|
||||
fn abbreviations(&self) -> Option<&HashMap<SmolStr, String>> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Returns a set of functions.
|
||||
fn functions(&self) -> Option<&HashSet<SmolStr>> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Returns a set of shell builtins.
|
||||
fn builtins(&self) -> Option<&HashSet<SmolStr>> {
|
||||
None
|
||||
}
|
||||
|
||||
fn shell_family(&self) -> Option<ShellFamily> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Keeps track of which separators characters are relevant in file paths.
|
||||
///
|
||||
/// There is a [`std::path::MAIN_SEPARATOR`], but we usually can't read that. We need to be dynamic
|
||||
/// in order to accommodate for sessions using a different separator from the system the app is
|
||||
/// running on, e.g. WSL or MSYS2.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PathSeparators {
|
||||
/// Analogous to [`std::path::MAIN_SEPARATOR`].
|
||||
pub main: char,
|
||||
/// Set of all valid separators, e.g. Windows recognizes both "/" and "\".
|
||||
pub all: &'static [char],
|
||||
}
|
||||
|
||||
impl PathSeparators {
|
||||
const WINDOWS_SEPARATORS: &[char] = &['/', '\\'];
|
||||
const UNIX_SEPARATORS: &[char] = &['/'];
|
||||
|
||||
pub fn for_os() -> Self {
|
||||
let main_separator = std::path::MAIN_SEPARATOR;
|
||||
Self {
|
||||
main: main_separator,
|
||||
all: match main_separator {
|
||||
'/' => Self::UNIX_SEPARATORS,
|
||||
'\\' => Self::WINDOWS_SEPARATORS,
|
||||
_ => panic!("unknown main path separator: {main_separator}"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn for_unix() -> Self {
|
||||
Self {
|
||||
main: '/',
|
||||
all: Self::UNIX_SEPARATORS,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn for_windows() -> Self {
|
||||
Self {
|
||||
main: '\\',
|
||||
all: Self::WINDOWS_SEPARATORS,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait PathCompletionContext: Send + Sync {
|
||||
/// Implementations should return a vector of entries (files/subdirectories) in the given
|
||||
/// `directory`.
|
||||
async fn list_directory_entries(&self, directory: TypedPathBuf) -> Arc<Vec<EngineDirEntry>>;
|
||||
|
||||
/// The "home" directory of the session.
|
||||
///
|
||||
/// This is used to expand '~' and '$HOME' in user input.
|
||||
fn home_directory(&self) -> Option<&str>;
|
||||
|
||||
fn shell_family(&self) -> ShellFamily;
|
||||
|
||||
/// The current working directory, which is used to determine how relative path suggestions
|
||||
/// should be computed.
|
||||
fn pwd(&self) -> TypedPath<'_>;
|
||||
|
||||
fn path_separators(&self) -> PathSeparators;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait GeneratorContext: Send + Sync {
|
||||
/// Execute a given command at the active pwd. If no session exist in the source, return None.
|
||||
async fn execute_command_at_pwd(
|
||||
&self,
|
||||
_shell_command: &str,
|
||||
_session_env_vars: Option<HashMap<String, String>>,
|
||||
) -> Result<CommandOutput>;
|
||||
|
||||
/// Whether the implementation allows execution of generators in parallel.
|
||||
fn supports_parallel_execution(&self) -> bool;
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct CommandOutput {
|
||||
pub stdout: Vec<u8>,
|
||||
pub stderr: Vec<u8>,
|
||||
pub status: CommandExitStatus,
|
||||
/// The exit code of the command. On Unix this can be None if the command was
|
||||
/// terminated by a signal.
|
||||
pub exit_code: Option<ExitCode>,
|
||||
}
|
||||
|
||||
impl CommandOutput {
|
||||
pub fn success(&self) -> bool {
|
||||
self.status == CommandExitStatus::Success
|
||||
}
|
||||
|
||||
// The output of the command, stdout if command was successful, stderr otherwise.
|
||||
pub fn output(&self) -> &Vec<u8> {
|
||||
if self.success() {
|
||||
&self.stdout
|
||||
} else {
|
||||
&self.stderr
|
||||
}
|
||||
}
|
||||
|
||||
pub fn exit_code(&self) -> Option<ExitCode> {
|
||||
self.exit_code
|
||||
}
|
||||
|
||||
pub fn to_string(&self) -> Result<String> {
|
||||
String::from_utf8(self.stdout.to_vec()).map_err(anyhow::Error::from)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
impl From<command::Output> for CommandOutput {
|
||||
fn from(other: command::Output) -> CommandOutput {
|
||||
let status = if other.status.success() {
|
||||
CommandExitStatus::Success
|
||||
} else {
|
||||
CommandExitStatus::Failure
|
||||
};
|
||||
CommandOutput {
|
||||
stdout: other.stdout,
|
||||
stderr: other.stderr,
|
||||
status,
|
||||
exit_code: other.status.code().map(ExitCode::from),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum CommandExitStatus {
|
||||
Success,
|
||||
Failure,
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
use async_trait::async_trait;
|
||||
use serde::{de::DeserializeOwned, Serialize};
|
||||
use warp_js::{JsFunctionId, SerializedJsValue, TypedJsFunctionRef};
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum JsExecutionError {
|
||||
#[error("Could not execute JS due to serialization error: {0:?}")]
|
||||
Serialization(bincode::Error),
|
||||
#[error("Could not execute JS due to deserialization error: {0:?}")]
|
||||
Deserialization(bincode::Error),
|
||||
#[error("Internal error occurred: {0}")]
|
||||
Internal(String),
|
||||
}
|
||||
|
||||
/// Trait to be implemented by callers using V2 command signatures. V2 command signatures are
|
||||
/// defined in JavaScript and may contain JS functions, which are internally represented with
|
||||
/// `TypedJsFunctionRef`s.
|
||||
#[async_trait]
|
||||
pub trait JsExecutionContext: Send + Sync {
|
||||
async fn call_js_function(
|
||||
&self,
|
||||
input: SerializedJsValue,
|
||||
function_id: JsFunctionId,
|
||||
) -> Result<SerializedJsValue, JsExecutionError>;
|
||||
}
|
||||
|
||||
/// Helper function for making typed JS function calls.
|
||||
pub(crate) async fn call_js_function<I, O>(
|
||||
input: &I,
|
||||
js_function_ref: &TypedJsFunctionRef<I, O>,
|
||||
js_ctx: &dyn JsExecutionContext,
|
||||
) -> Result<O, JsExecutionError>
|
||||
where
|
||||
I: Serialize,
|
||||
O: DeserializeOwned,
|
||||
{
|
||||
let serialized_input =
|
||||
SerializedJsValue::from_value(input).map_err(JsExecutionError::Serialization)?;
|
||||
let serialized_output = js_ctx
|
||||
.call_js_function(serialized_input, js_function_ref.id)
|
||||
.await?;
|
||||
let output: O = serialized_output
|
||||
.to_value()
|
||||
.map_err(JsExecutionError::Deserialization)?;
|
||||
Ok(output)
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
use itertools::Itertools;
|
||||
use string_offset::ByteOffset;
|
||||
use warpui::platform::OperatingSystem;
|
||||
|
||||
use crate::{
|
||||
completer::suggest::MatchRequirement,
|
||||
meta::{HasSpan, Span, Spanned},
|
||||
};
|
||||
use crate::{meta::SpannedItem, parsers::simple::command_at_cursor_position};
|
||||
|
||||
use super::suggest::{suggestions, CompleterOptions, CompletionsFallbackStrategy, SuggestionType};
|
||||
use super::{context::CompletionContext, get_path_separators};
|
||||
use super::{Match, MatchStrategy};
|
||||
|
||||
#[derive(Debug, Eq, PartialEq, Clone)]
|
||||
pub struct Description {
|
||||
pub token: Spanned<String>,
|
||||
pub description_text: Option<String>,
|
||||
pub suggestion_type: SuggestionType,
|
||||
}
|
||||
|
||||
impl Description {
|
||||
pub fn a11y_text(&self) -> String {
|
||||
match &self.description_text {
|
||||
Some(description_text) => format!(
|
||||
"Command inspector triggered for {}, {}",
|
||||
self.token.item, description_text
|
||||
),
|
||||
None => format!("Command inspector triggered for {}", self.token.item,),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Describes the case sensitivity used when parsing a top-level command.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum TopLevelCommandCaseSensitivity {
|
||||
CaseSensitive,
|
||||
CaseInsensitive,
|
||||
}
|
||||
|
||||
impl TopLevelCommandCaseSensitivity {
|
||||
pub fn from_os_category(os_category: &str) -> Self {
|
||||
match os_category.to_lowercase().as_str() {
|
||||
"windows" | "macos" => Self::CaseInsensitive,
|
||||
_ => Self::CaseSensitive,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<OperatingSystem> for TopLevelCommandCaseSensitivity {
|
||||
fn from(value: OperatingSystem) -> Self {
|
||||
match value {
|
||||
OperatingSystem::Mac | OperatingSystem::Windows => Self::CaseInsensitive,
|
||||
_ => Self::CaseSensitive,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<TopLevelCommandCaseSensitivity> for MatchStrategy {
|
||||
fn from(value: TopLevelCommandCaseSensitivity) -> Self {
|
||||
match value {
|
||||
TopLevelCommandCaseSensitivity::CaseSensitive => Self::CaseSensitive,
|
||||
TopLevelCommandCaseSensitivity::CaseInsensitive => Self::CaseInsensitive,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum OptionCaseSensitivity {
|
||||
CaseSensitive,
|
||||
CaseInsensitive,
|
||||
}
|
||||
|
||||
impl From<OptionCaseSensitivity> for MatchStrategy {
|
||||
fn from(value: OptionCaseSensitivity) -> Self {
|
||||
match value {
|
||||
OptionCaseSensitivity::CaseSensitive => Self::CaseSensitive,
|
||||
OptionCaseSensitivity::CaseInsensitive => Self::CaseInsensitive,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Describes the part of the command at the given cursor location, by using the completion results of the
|
||||
/// command at that location and then matching those results with the token to extract the desired description.
|
||||
///
|
||||
/// For example, with the line `git commit` and pos 1, the function would return a Description
|
||||
/// struct for the command `git`
|
||||
pub async fn describe<T: CompletionContext>(
|
||||
line: &str,
|
||||
pos: ByteOffset,
|
||||
context: &T,
|
||||
) -> Option<Description> {
|
||||
let command_to_describe = command_at_cursor_position(line, context.escape_char(), pos);
|
||||
match command_to_describe {
|
||||
Some(command) => {
|
||||
let command_span = command
|
||||
.parts
|
||||
.iter()
|
||||
.find(|token| {
|
||||
token.span.start() <= pos.as_usize() && token.span.end() >= pos.as_usize()
|
||||
})
|
||||
.cloned();
|
||||
|
||||
match command_span {
|
||||
Some(token) => {
|
||||
// For --flag=value tokens, split based on cursor position so hovering the flag
|
||||
// part describes the flag and hovering the value part describes the value.
|
||||
let token = split_flag_eq_token_at_cursor(token, pos);
|
||||
describe_given_token(line, &command.span(), token, context).await
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a completion description for the given token in the command.
|
||||
/// line is the entire input.
|
||||
/// command_span is the span for the command we're describing.
|
||||
/// token is the specific span of the word we're describing.
|
||||
/// e.g. line = "git status && cd dir", command_span = "git status", token = "status"
|
||||
pub async fn describe_given_token<T: CompletionContext>(
|
||||
line: &str,
|
||||
command_span: &Span,
|
||||
token: Spanned<String>,
|
||||
context: &T,
|
||||
) -> Option<Description> {
|
||||
let path_separators = get_path_separators(context).all;
|
||||
|
||||
// If the filepath ends with a separator, we need to run the completer with the
|
||||
// separator trimmed. Otherwise, the completer won't return suggestions for the
|
||||
// current directory. For example, if we have `cd foo`, the completer will return
|
||||
// a suggestion for `foo` so we can properly describe it. With `cd foo/`, the
|
||||
// completer would only return suggestions for subdirectories of `foo`, so we
|
||||
// wouldn't be able to describe it.
|
||||
let token_end = if token.item.ends_with(path_separators) {
|
||||
token.span().end() - 1
|
||||
} else {
|
||||
token.span().end()
|
||||
};
|
||||
let start_raw_byte_index = command_span.start();
|
||||
let end_raw_byte_index = start_raw_byte_index.max(token_end);
|
||||
let start = floor_char_boundary(line, start_raw_byte_index);
|
||||
let end = floor_char_boundary(line, end_raw_byte_index);
|
||||
let complete_on = &line[start..end];
|
||||
|
||||
let results = suggestions(
|
||||
complete_on,
|
||||
complete_on.len(),
|
||||
None,
|
||||
CompleterOptions {
|
||||
// Use a case-insensitive matcher here since we need case-insensitive matches for command
|
||||
// suggestion types. We do a final match based on the correct command type below.
|
||||
match_strategy: MatchStrategy::CaseInsensitive,
|
||||
fallback_strategy: CompletionsFallbackStrategy::FilePaths,
|
||||
suggest_file_path_completions_only: false,
|
||||
parse_quotes_as_literals: false,
|
||||
},
|
||||
context,
|
||||
)
|
||||
.await;
|
||||
|
||||
let trimmed_token_item = token.item.trim_end_matches(path_separators);
|
||||
|
||||
results.and_then(|results| {
|
||||
let mut prefix_matches = vec![];
|
||||
for suggestion in results.suggestions {
|
||||
let matching_suggestion_token = if suggestion.is_abbreviation() {
|
||||
// For abbreviations, we match the token with the display
|
||||
// text of the suggestion because the replacement text is
|
||||
// the expanded form of the abbreviation.
|
||||
suggestion.display()
|
||||
} else {
|
||||
// TODO: add a property on the Suggestion type so we don't have to keep recomputing this
|
||||
suggestion.replacement().trim_end_matches(path_separators)
|
||||
};
|
||||
|
||||
let matcher = match suggestion.suggestion_type() {
|
||||
SuggestionType::Command(case_sensitivity) => case_sensitivity.into(),
|
||||
SuggestionType::Option(_, case_sensitivity) => case_sensitivity.into(),
|
||||
_ => MatchStrategy::CaseSensitive,
|
||||
};
|
||||
|
||||
match (
|
||||
matcher.get_match_type(trimmed_token_item, matching_suggestion_token),
|
||||
suggestion.suggestion_type(),
|
||||
) {
|
||||
(Some(Match::Exact { .. }), _) => {
|
||||
return Some(Description {
|
||||
token: match suggestion.suggestion_type() {
|
||||
// For top-level commands `suggestion.display()` contains the preferred
|
||||
// stylization, e.g. "Get-Help" instead of "get-help".
|
||||
SuggestionType::Command(_) => {
|
||||
suggestion.display().to_owned().spanned(token.span())
|
||||
}
|
||||
// For all other suggestion types, show it exactly as the user typed
|
||||
// it.
|
||||
_ => token.clone(),
|
||||
},
|
||||
description_text: suggestion.description(),
|
||||
suggestion_type: suggestion.suggestion_type(),
|
||||
});
|
||||
}
|
||||
(
|
||||
Some(Match::Prefix { .. }),
|
||||
SuggestionType::Option(MatchRequirement::UniquePrefixOnly, _),
|
||||
) => {
|
||||
prefix_matches.push(Description {
|
||||
token: suggestion.display().to_owned().spanned(token.span()),
|
||||
description_text: suggestion.description(),
|
||||
suggestion_type: suggestion.suggestion_type(),
|
||||
});
|
||||
}
|
||||
_ => (),
|
||||
};
|
||||
}
|
||||
prefix_matches.into_iter().exactly_one().ok()
|
||||
})
|
||||
}
|
||||
|
||||
/// If the token is a `--flag=value` token, returns a sub-token for the part the
|
||||
/// cursor is on: the flag name part if the cursor is on or before the `=`, or the
|
||||
/// value part if the cursor is after the `=`.
|
||||
fn split_flag_eq_token_at_cursor(token: Spanned<String>, pos: ByteOffset) -> Spanned<String> {
|
||||
if !token.item.starts_with('-') {
|
||||
return token;
|
||||
}
|
||||
let Some(eq_pos) = token.item.find('=') else {
|
||||
return token;
|
||||
};
|
||||
let eq_byte_pos = token.span.start() + eq_pos;
|
||||
if pos.as_usize() <= eq_byte_pos {
|
||||
// Cursor is on the flag name part (including '=').
|
||||
token.item[..eq_pos]
|
||||
.to_string()
|
||||
.spanned(Span::new(token.span.start(), eq_byte_pos))
|
||||
} else {
|
||||
// Cursor is on the value part.
|
||||
token.item[eq_pos + 1..]
|
||||
.to_string()
|
||||
.spanned(Span::new(eq_byte_pos + 1, token.span.end()))
|
||||
}
|
||||
}
|
||||
|
||||
/// TODO: replace with str::floor_char_boundary once it's not on nightly anymore.
|
||||
fn floor_char_boundary(original_string: &str, idx: usize) -> usize {
|
||||
if idx >= original_string.len() {
|
||||
original_string.len()
|
||||
} else {
|
||||
let mut curr = idx;
|
||||
// Stop at zero since it's always a char boundary.
|
||||
while curr > 0 && !original_string.is_char_boundary(curr) {
|
||||
curr -= 1;
|
||||
}
|
||||
curr
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, not(feature = "v2")))]
|
||||
#[path = "describe_test.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,536 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::iter::FromIterator;
|
||||
|
||||
use string_offset::ByteOffset;
|
||||
use typed_path::TypedPathBuf;
|
||||
|
||||
use crate::completer::EngineDirEntry;
|
||||
use crate::completer::{context::CompletionContext, suggest::MatchRequirement};
|
||||
use crate::completer::{
|
||||
describe::OptionCaseSensitivity,
|
||||
testing::{FakeCompletionContext, MockPathCompletionContext},
|
||||
};
|
||||
use crate::completer::{suggest::SuggestionType, TopLevelCommandCaseSensitivity};
|
||||
use crate::meta::{Span, SpannedItem};
|
||||
use crate::signatures::{
|
||||
testing::{add_content_signature, create_test_command_registry, git_signature, test_signature},
|
||||
CommandRegistry,
|
||||
};
|
||||
|
||||
use super::{describe, Description};
|
||||
|
||||
#[cfg(windows)]
|
||||
mod windows_constants {
|
||||
pub(super) const TEST_WORK_DIR: &str = r"C:\";
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
use windows_constants::*;
|
||||
|
||||
#[cfg(unix)]
|
||||
mod unix_constants {
|
||||
pub(super) const TEST_WORK_DIR: &str = "/home/";
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
use unix_constants::*;
|
||||
|
||||
/// Given a line and position in the line, runs the completer at the position and returns
|
||||
/// a Description struct for the word at pos
|
||||
fn describe_at_cursor<T: CompletionContext>(
|
||||
line: &str,
|
||||
pos: ByteOffset,
|
||||
ctx: &T,
|
||||
) -> Option<Description> {
|
||||
warpui::r#async::block_on(describe(line, pos, ctx))
|
||||
}
|
||||
|
||||
#[test]
|
||||
pub fn test_describe_top_level_commands_case_sensitive() {
|
||||
let ctx = FakeCompletionContext::new(CommandRegistry::default())
|
||||
.with_case_sensitivity()
|
||||
.with_top_level_commands(vec!["git", "networkQuality"]);
|
||||
|
||||
assert_eq!(
|
||||
describe_at_cursor("git", ByteOffset::from(1), &ctx).map(Description::into_token_name),
|
||||
Some("git".into())
|
||||
);
|
||||
|
||||
assert!(describe_at_cursor("GIT", ByteOffset::from(1), &ctx)
|
||||
.map(Description::into_token_name)
|
||||
.is_none());
|
||||
|
||||
assert!(describe_at_cursor("GIt", ByteOffset::from(1), &ctx)
|
||||
.map(Description::into_token_name)
|
||||
.is_none());
|
||||
|
||||
// The `TopLevelCommandCaseSensitivity` value does not matter since we check parts other than the top-level command.
|
||||
assert_eq!(
|
||||
describe_at_cursor("git status", ByteOffset::from(4), &ctx)
|
||||
.map(Description::into_token_name),
|
||||
Some("status".into())
|
||||
);
|
||||
|
||||
// There should be no descriptions for `git Status` since `Status` is not a valid
|
||||
// subcommand.
|
||||
assert!(describe_at_cursor("git Status", ByteOffset::from(4), &ctx).is_none());
|
||||
|
||||
assert_eq!(
|
||||
describe_at_cursor("git status --ahead-behind", ByteOffset::from(14), &ctx)
|
||||
.map(Description::into_token_name),
|
||||
Some("--ahead-behind".into())
|
||||
);
|
||||
|
||||
assert!(describe_at_cursor("git status --AHEAD-behind", ByteOffset::from(14), &ctx).is_none());
|
||||
|
||||
assert_eq!(
|
||||
describe_at_cursor("networkQuality", ByteOffset::from(1), &ctx)
|
||||
.map(Description::into_token_name),
|
||||
Some("networkQuality".into())
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
pub fn test_describe_top_level_commands_case_insensitive() {
|
||||
let ctx = FakeCompletionContext::new(CommandRegistry::default())
|
||||
.with_top_level_commands(vec!["git", "networkQuality"]);
|
||||
|
||||
assert_eq!(
|
||||
describe_at_cursor("git", ByteOffset::from(1), &ctx).map(Description::into_token_name),
|
||||
Some("git".into())
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
describe_at_cursor("GIT", ByteOffset::from(1), &ctx).map(Description::into_token_name),
|
||||
Some("git".into())
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
describe_at_cursor("GIt", ByteOffset::from(1), &ctx).map(Description::into_token_name),
|
||||
Some("git".into())
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
describe_at_cursor("git status && GIT checkout", ByteOffset::from(15), &ctx)
|
||||
.map(Description::into_token_name),
|
||||
Some("git".into())
|
||||
);
|
||||
|
||||
// The `TopLevelCommandCaseSensitivity` value does not matter since we check parts other than the top-level command.
|
||||
assert_eq!(
|
||||
describe_at_cursor("git status", ByteOffset::from(4), &ctx)
|
||||
.map(Description::into_token_name),
|
||||
Some("status".into())
|
||||
);
|
||||
|
||||
// There should be no descriptions for `git Status` since `Status` is not a valid
|
||||
// subcommand.
|
||||
assert!(describe_at_cursor("git Status", ByteOffset::from(4), &ctx).is_none());
|
||||
|
||||
assert_eq!(
|
||||
describe_at_cursor("git status --ahead-behind", ByteOffset::from(14), &ctx)
|
||||
.map(Description::into_token_name),
|
||||
Some("--ahead-behind".into())
|
||||
);
|
||||
|
||||
assert!(describe_at_cursor("git status --AHEAD-behind", ByteOffset::from(14), &ctx).is_none());
|
||||
|
||||
assert_eq!(
|
||||
describe_at_cursor("networkQuality", ByteOffset::from(1), &ctx)
|
||||
.map(Description::into_token_name),
|
||||
Some("networkQuality".into())
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
pub fn test_xray_describe() {
|
||||
let ctx = FakeCompletionContext::new(CommandRegistry::default())
|
||||
.with_top_level_commands(["git"])
|
||||
.with_environment_variable_names(HashSet::from(["HOME".into()]));
|
||||
|
||||
let line = r"git status $(git stash) && git checkout main && $HOME";
|
||||
|
||||
assert_eq!(
|
||||
describe_at_cursor(line, ByteOffset::from(1), &ctx),
|
||||
Some(Description {
|
||||
token: "git".to_string().spanned(Span::new(0, 3)),
|
||||
description_text: Some("The stupid content tracker".to_string()),
|
||||
suggestion_type: SuggestionType::Command(
|
||||
TopLevelCommandCaseSensitivity::CaseInsensitive
|
||||
),
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
describe_at_cursor(line, ByteOffset::from(5), &ctx),
|
||||
Some(Description {
|
||||
token: "status".to_string().spanned(Span::new(4, 10)),
|
||||
description_text: Some("Show the working tree status".to_string()),
|
||||
suggestion_type: SuggestionType::Subcommand
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
describe_at_cursor(line, ByteOffset::from(13), &ctx),
|
||||
Some(Description {
|
||||
token: "git".to_string().spanned(Span::new(13, 16)),
|
||||
description_text: Some("The stupid content tracker".to_string()),
|
||||
suggestion_type: SuggestionType::Command(
|
||||
TopLevelCommandCaseSensitivity::CaseInsensitive
|
||||
),
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
describe_at_cursor(line, ByteOffset::from(18), &ctx),
|
||||
Some(Description {
|
||||
token: "stash".to_string().spanned(Span::new(17, 22)),
|
||||
description_text: Some("Temporarily stores all the modified tracked files".to_string()),
|
||||
suggestion_type: SuggestionType::Subcommand
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
describe_at_cursor(line, ByteOffset::from(28), &ctx),
|
||||
Some(Description {
|
||||
token: "git".to_string().spanned(Span::new(27, 30)),
|
||||
description_text: Some("The stupid content tracker".to_string()),
|
||||
suggestion_type: SuggestionType::Command(
|
||||
TopLevelCommandCaseSensitivity::CaseInsensitive
|
||||
),
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
describe_at_cursor(line, ByteOffset::from(33), &ctx),
|
||||
Some(Description {
|
||||
token: "checkout".to_string().spanned(Span::new(31, 39)),
|
||||
description_text: Some("Switch branches or restore working tree files".to_string()),
|
||||
suggestion_type: SuggestionType::Subcommand
|
||||
},)
|
||||
);
|
||||
assert_eq!(
|
||||
describe_at_cursor(line, ByteOffset::from(50), &ctx),
|
||||
Some(Description {
|
||||
token: "$HOME".to_string().spanned(Span::new(48, 53)),
|
||||
description_text: None,
|
||||
suggestion_type: SuggestionType::Variable
|
||||
},)
|
||||
);
|
||||
assert!(describe_at_cursor(line, ByteOffset::from(25), &ctx).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
pub fn test_xray_describe_with_flags() {
|
||||
let ctx = FakeCompletionContext::new(CommandRegistry::default());
|
||||
|
||||
let mut line = r"git commit -am";
|
||||
assert_eq!(
|
||||
describe_at_cursor(line, ByteOffset::from(11), &ctx),
|
||||
Some(Description {
|
||||
token: "-am".to_string().spanned(Span::new(11, 14)),
|
||||
description_text: Some("Use the given message as the commit message".to_string()),
|
||||
suggestion_type: SuggestionType::Option(
|
||||
MatchRequirement::EntireName,
|
||||
OptionCaseSensitivity::CaseSensitive
|
||||
)
|
||||
})
|
||||
);
|
||||
|
||||
line = "git commit -a";
|
||||
assert_eq!(
|
||||
describe_at_cursor(line, ByteOffset::from(11), &ctx),
|
||||
Some(Description {
|
||||
token: "-a".to_string().spanned(Span::new(11, 13)),
|
||||
description_text: Some("Stage all modified and deleted paths".to_string()),
|
||||
suggestion_type: SuggestionType::Option(
|
||||
MatchRequirement::EntireName,
|
||||
OptionCaseSensitivity::CaseSensitive
|
||||
)
|
||||
})
|
||||
);
|
||||
|
||||
line = "git commit --all";
|
||||
assert_eq!(
|
||||
describe_at_cursor(line, ByteOffset::from(11), &ctx),
|
||||
Some(Description {
|
||||
token: "--all".to_string().spanned(Span::new(11, 16)),
|
||||
description_text: Some("Stage all modified and deleted paths".to_string()),
|
||||
suggestion_type: SuggestionType::Option(
|
||||
MatchRequirement::EntireName,
|
||||
OptionCaseSensitivity::CaseSensitive
|
||||
)
|
||||
})
|
||||
);
|
||||
|
||||
line = "git commit --All";
|
||||
assert_eq!(describe_at_cursor(line, ByteOffset::from(11), &ctx), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
pub fn test_xray_describe_with_directories() {
|
||||
let pwd = TypedPathBuf::from(TEST_WORK_DIR);
|
||||
let path_ctx = MockPathCompletionContext::new(pwd.clone())
|
||||
.with_entries_in_pwd([
|
||||
EngineDirEntry::test_dir("foo"),
|
||||
EngineDirEntry::test_file("foobar"),
|
||||
])
|
||||
.with_entries(pwd.join("foo/"), [EngineDirEntry::test_dir("src")])
|
||||
.with_entries(pwd.join("foo/src/"), [EngineDirEntry::test_file("bar")]);
|
||||
|
||||
let ctx = FakeCompletionContext::new(CommandRegistry::default())
|
||||
.with_path_completion_context(path_ctx);
|
||||
|
||||
let mut line = r"ls foo/ && cd foo/src";
|
||||
|
||||
assert_eq!(
|
||||
describe_at_cursor(line, ByteOffset::from(5), &ctx),
|
||||
Some(Description {
|
||||
token: "foo/".to_string().spanned(Span::new(3, 7)),
|
||||
description_text: Some("Directory".to_string()),
|
||||
suggestion_type: SuggestionType::Argument
|
||||
})
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
describe_at_cursor(line, ByteOffset::from(17), &ctx),
|
||||
Some(Description {
|
||||
token: "foo/src".to_string().spanned(Span::new(14, 21)),
|
||||
description_text: Some("Directory".to_string()),
|
||||
suggestion_type: SuggestionType::Argument,
|
||||
})
|
||||
);
|
||||
|
||||
line = r"cat foo/src/bar && cat foobar";
|
||||
|
||||
assert_eq!(
|
||||
describe_at_cursor(line, ByteOffset::from(5), &ctx),
|
||||
Some(Description {
|
||||
token: "foo/src/bar".to_string().spanned(Span::new(4, 15)),
|
||||
description_text: Some("File".to_string()),
|
||||
suggestion_type: SuggestionType::Argument
|
||||
})
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
describe_at_cursor(line, ByteOffset::from(25), &ctx),
|
||||
Some(Description {
|
||||
token: "foobar".to_string().spanned(Span::new(23, 29)),
|
||||
description_text: Some("File".to_string()),
|
||||
suggestion_type: SuggestionType::Argument,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression test for linear issues WAR-4244 and WAR-4245
|
||||
#[test]
|
||||
pub fn test_xray_describe_with_non_ascii_chars() {
|
||||
let registry = create_test_command_registry([git_signature()]);
|
||||
let ctx = FakeCompletionContext::new(registry);
|
||||
|
||||
let line = r"漢字 && git checkout 漢字";
|
||||
|
||||
assert_eq!(
|
||||
describe_at_cursor(line, ByteOffset::from(24), &ctx),
|
||||
Some(Description {
|
||||
token: "漢字".to_string().spanned(Span::new(23, 29)),
|
||||
description_text: None,
|
||||
suggestion_type: SuggestionType::Argument,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
pub fn test_xray_describe_single_char_line() {
|
||||
let aliases = HashMap::from_iter([("g".into(), "git".into())]);
|
||||
let ctx = FakeCompletionContext::new(CommandRegistry::default())
|
||||
.with_aliases(aliases.clone())
|
||||
.with_top_level_commands(aliases.into_keys());
|
||||
|
||||
let line = "g";
|
||||
|
||||
assert_eq!(
|
||||
describe_at_cursor(line, ByteOffset::from(0), &ctx),
|
||||
Some(Description {
|
||||
token: "g".to_string().spanned(Span::new(0, 1)),
|
||||
description_text: Some("Alias for \"git\"".to_string()),
|
||||
suggestion_type: SuggestionType::Command(TopLevelCommandCaseSensitivity::CaseSensitive),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
pub fn test_xray_describe_ndots() {
|
||||
let aliases = HashMap::from_iter([("...".into(), "cd ../../".into())]);
|
||||
let ctx = FakeCompletionContext::new(CommandRegistry::default())
|
||||
.with_aliases(aliases.clone())
|
||||
.with_top_level_commands(aliases.into_keys());
|
||||
|
||||
let line = "...";
|
||||
|
||||
assert_eq!(
|
||||
describe_at_cursor(line, ByteOffset::from(0), &ctx),
|
||||
Some(Description {
|
||||
token: "...".to_string().spanned(Span::new(0, 3)),
|
||||
description_text: Some("Alias for \"cd ../../\"".to_string()),
|
||||
suggestion_type: SuggestionType::Command(TopLevelCommandCaseSensitivity::CaseSensitive),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
pub fn test_xray_describe_functions() {
|
||||
let functions = HashSet::from_iter(["foo".into()]);
|
||||
let ctx = FakeCompletionContext::new(CommandRegistry::default())
|
||||
.with_functions(functions.clone())
|
||||
.with_top_level_commands(functions);
|
||||
|
||||
let line = "foo";
|
||||
|
||||
assert_eq!(
|
||||
describe_at_cursor(line, ByteOffset::from(0), &ctx),
|
||||
Some(Description {
|
||||
token: "foo".to_string().spanned(Span::new(0, 3)),
|
||||
description_text: Some("Shell function".to_string()),
|
||||
suggestion_type: SuggestionType::Command(TopLevelCommandCaseSensitivity::CaseSensitive),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
pub fn test_xray_describe_builtins() {
|
||||
let builtins = HashSet::from_iter(["exit".into()]);
|
||||
let ctx = FakeCompletionContext::new(CommandRegistry::default())
|
||||
.with_builtins(builtins.clone())
|
||||
.with_top_level_commands(builtins);
|
||||
|
||||
let line = "exit";
|
||||
|
||||
assert_eq!(
|
||||
describe_at_cursor(line, ByteOffset::from(0), &ctx),
|
||||
Some(Description {
|
||||
token: "exit".to_string().spanned(Span::new(0, 4)),
|
||||
description_text: Some("Shell builtin".to_string()),
|
||||
suggestion_type: SuggestionType::Command(TopLevelCommandCaseSensitivity::CaseSensitive),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
pub fn test_xray_describe_abbreviations() {
|
||||
let abbrs = HashMap::from_iter([("ga".into(), "git add".into())]);
|
||||
let ctx = FakeCompletionContext::new(CommandRegistry::default())
|
||||
.with_abbreviations(abbrs.clone())
|
||||
.with_top_level_commands(abbrs.into_keys());
|
||||
|
||||
let line = "ga";
|
||||
|
||||
assert_eq!(
|
||||
describe_at_cursor(line, ByteOffset::from(0), &ctx),
|
||||
Some(Description {
|
||||
token: "ga".to_string().spanned(Span::new(0, 2)),
|
||||
description_text: Some("Abbreviation for \"git add\"".to_string()),
|
||||
suggestion_type: SuggestionType::Command(TopLevelCommandCaseSensitivity::CaseSensitive),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
pub fn test_xray_describe_flag_with_equal_sign() {
|
||||
let registry = create_test_command_registry([test_signature()]);
|
||||
let ctx = FakeCompletionContext::new(registry);
|
||||
|
||||
assert_eq!(
|
||||
describe_at_cursor("test --long=foo", ByteOffset::from(7), &ctx),
|
||||
Some(Description {
|
||||
token: "--long".to_string().spanned(Span::new(5, 11)),
|
||||
description_text: None,
|
||||
suggestion_type: SuggestionType::Option(
|
||||
MatchRequirement::EntireName,
|
||||
OptionCaseSensitivity::CaseSensitive,
|
||||
),
|
||||
})
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
describe_at_cursor("test --long=", ByteOffset::from(7), &ctx),
|
||||
Some(Description {
|
||||
token: "--long".to_string().spanned(Span::new(5, 11)),
|
||||
description_text: None,
|
||||
suggestion_type: SuggestionType::Option(
|
||||
MatchRequirement::EntireName,
|
||||
OptionCaseSensitivity::CaseSensitive,
|
||||
),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
pub fn test_describe_file_paths_with_separator_in_middle() {
|
||||
let registry = create_test_command_registry([]);
|
||||
let pwd = TypedPathBuf::from(TEST_WORK_DIR);
|
||||
let path_ctx = MockPathCompletionContext::new(pwd.clone())
|
||||
.with_entries_in_pwd([EngineDirEntry::test_dir("foo")])
|
||||
.with_entries(pwd.join("foo/"), [EngineDirEntry::test_file("script")]);
|
||||
let ctx = FakeCompletionContext::new(registry).with_path_completion_context(path_ctx.clone());
|
||||
|
||||
assert_eq!(
|
||||
describe_at_cursor("foo/script", ByteOffset::from(10), &ctx),
|
||||
Some(Description {
|
||||
token: "foo/script".to_string().spanned(Span::new(0, 10)),
|
||||
description_text: Some("File".to_string()),
|
||||
suggestion_type: SuggestionType::Argument
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_describe_powershell_shortened_option() {
|
||||
let registry = create_test_command_registry([add_content_signature(), git_signature()]);
|
||||
let ctx = FakeCompletionContext::new(registry);
|
||||
|
||||
// "-Enc" is specific enough to match "-Encoding" but not "-Exclude"
|
||||
assert_eq!(
|
||||
describe_at_cursor("Add-Content -Enc ASCII", ByteOffset::from(16), &ctx),
|
||||
Some(Description {
|
||||
token: "-Encoding".to_string().spanned(Span::new(12, 16)),
|
||||
description_text: None,
|
||||
suggestion_type: SuggestionType::Option(
|
||||
MatchRequirement::UniquePrefixOnly,
|
||||
OptionCaseSensitivity::CaseInsensitive
|
||||
),
|
||||
})
|
||||
);
|
||||
|
||||
// "-E" is not specific enough, so it shouldn't match.
|
||||
assert_eq!(
|
||||
describe_at_cursor("Add-Content -E ASCII", ByteOffset::from(14), &ctx),
|
||||
None
|
||||
);
|
||||
|
||||
// Shouldn't apply to commands which aren't PowerShell cmdlets
|
||||
assert_eq!(
|
||||
describe_at_cursor("git branch --delet", ByteOffset::from(18), &ctx),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_describe_case_insensitive_option() {
|
||||
let registry = create_test_command_registry([add_content_signature(), git_signature()]);
|
||||
let ctx = FakeCompletionContext::new(registry);
|
||||
|
||||
// "-enc" is specific enough to match "-Encoding" but not "-Exclude"
|
||||
assert_eq!(
|
||||
describe_at_cursor("Add-Content -enc UTF8", ByteOffset::from(16), &ctx),
|
||||
Some(Description {
|
||||
token: "-Encoding".to_string().spanned(Span::new(12, 16)),
|
||||
description_text: None,
|
||||
suggestion_type: SuggestionType::Option(
|
||||
MatchRequirement::UniquePrefixOnly,
|
||||
OptionCaseSensitivity::CaseInsensitive
|
||||
),
|
||||
})
|
||||
);
|
||||
|
||||
// "-e" is not specific enough, so it shouldn't match.
|
||||
assert_eq!(
|
||||
describe_at_cursor("Add-Content -e ASCII", ByteOffset::from(14), &ctx),
|
||||
None
|
||||
);
|
||||
}
|
||||
@@ -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 warp_core::features::FeatureFlag;
|
||||
use warp_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 warp_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!(
|
||||
warpui::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!(
|
||||
warpui::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!(
|
||||
warpui::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!(
|
||||
warpui::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!(
|
||||
warpui::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> = warpui::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!(
|
||||
warpui::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!(
|
||||
warpui::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!(
|
||||
warpui::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!(
|
||||
warpui::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!(
|
||||
warpui::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 warp_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()
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use fuzzy_match::{match_indices_case_insensitive, FuzzyMatchResult};
|
||||
|
||||
/// Determine if `from` starts with `partial` in a case insensitive manner.
|
||||
/// Returns None if `partial` does not start with `from`, otherwise specifying
|
||||
/// whether the match is exact or a prefix, and whether it is case sensitive.
|
||||
fn match_type_for_case_insensitive(partial: &str, from: &str) -> Option<Match> {
|
||||
if partial.len() > from.len() {
|
||||
return None;
|
||||
}
|
||||
let mut starts_with = true;
|
||||
let mut is_case_sensitive = true;
|
||||
for (a, b) in from.chars().zip(partial.chars()) {
|
||||
if a == b {
|
||||
continue;
|
||||
} else if a.eq_ignore_ascii_case(&b) {
|
||||
is_case_sensitive = false;
|
||||
} else {
|
||||
starts_with = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
let same_length = partial.len() == from.len();
|
||||
starts_with.then_some(match same_length {
|
||||
true => Match::Exact { is_case_sensitive },
|
||||
false => Match::Prefix { is_case_sensitive },
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum MatchStrategy {
|
||||
/// Yields only case-sensitive matches, otherwise yields None.
|
||||
CaseSensitive,
|
||||
/// Yields both case-sensitive and case-insensitive matches, otherwise yields None.
|
||||
CaseInsensitive,
|
||||
/// Yields both case-sensitive and case-insensitive matches. If there is no
|
||||
/// exact/prefix match result, it will try to find a fuzzy (i.e. approximate)
|
||||
/// match result.
|
||||
Fuzzy,
|
||||
}
|
||||
|
||||
impl MatchStrategy {
|
||||
/// Given the matcher variant, return a MatchType if partial matches from.
|
||||
/// Note that this function will return the most specific match type (irrespective
|
||||
/// of the matcher). For example, a fuzzy matcher will return an Exact match
|
||||
/// for partial="git" and from="git" even though a Prefix match and Fuzzy match
|
||||
/// is also technically correct.
|
||||
pub fn get_match_type(&self, partial: &str, from: &str) -> Option<Match> {
|
||||
use Match::*;
|
||||
|
||||
match self {
|
||||
MatchStrategy::CaseSensitive => {
|
||||
if from == partial {
|
||||
Some(Exact {
|
||||
is_case_sensitive: true,
|
||||
})
|
||||
} else if from.starts_with(partial) {
|
||||
Some(Prefix {
|
||||
is_case_sensitive: true,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
MatchStrategy::CaseInsensitive => match_type_for_case_insensitive(partial, from),
|
||||
MatchStrategy::Fuzzy => {
|
||||
let case_insensitive_match = match_type_for_case_insensitive(partial, from);
|
||||
if case_insensitive_match.is_some() {
|
||||
return case_insensitive_match;
|
||||
}
|
||||
|
||||
match_indices_case_insensitive(from, partial)
|
||||
.map(|match_result| Fuzzy { match_result })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Hash, PartialEq, Eq)]
|
||||
pub enum Match {
|
||||
Prefix { is_case_sensitive: bool },
|
||||
Exact { is_case_sensitive: bool },
|
||||
Fuzzy { match_result: FuzzyMatchResult },
|
||||
}
|
||||
|
||||
/// How precisely a search pattern matches its result.
|
||||
#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
|
||||
pub enum MatchType {
|
||||
Prefix {
|
||||
is_case_sensitive: bool,
|
||||
},
|
||||
Exact {
|
||||
is_case_sensitive: bool,
|
||||
},
|
||||
Fuzzy,
|
||||
/// The `Other` variant is used when we have matches that aren't related to the
|
||||
/// search pattern, for example, workflow enum suggestions
|
||||
Other,
|
||||
}
|
||||
|
||||
impl From<Match> for MatchType {
|
||||
fn from(match_type: Match) -> Self {
|
||||
match match_type {
|
||||
Match::Prefix { is_case_sensitive } => MatchType::Prefix { is_case_sensitive },
|
||||
Match::Exact { is_case_sensitive } => MatchType::Exact { is_case_sensitive },
|
||||
Match::Fuzzy { .. } => MatchType::Fuzzy,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "matchers_test.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,94 @@
|
||||
use crate::completer::matchers::match_type_for_case_insensitive;
|
||||
|
||||
use super::{Match, MatchStrategy};
|
||||
|
||||
#[test]
|
||||
fn test_match_type_for_case_insensitive() {
|
||||
assert_eq!(
|
||||
match_type_for_case_insensitive("git", "git"),
|
||||
Some(Match::Exact {
|
||||
is_case_sensitive: true
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
match_type_for_case_insensitive("gIt", "git"),
|
||||
Some(Match::Exact {
|
||||
is_case_sensitive: false
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
match_type_for_case_insensitive("abc", "abcdef"),
|
||||
Some(Match::Prefix {
|
||||
is_case_sensitive: true
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
match_type_for_case_insensitive("aBc", "abcdef"),
|
||||
Some(Match::Prefix {
|
||||
is_case_sensitive: false
|
||||
})
|
||||
);
|
||||
assert_eq!(match_type_for_case_insensitive("abc", "def"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_match_type_case_sensitive() {
|
||||
let matcher = MatchStrategy::CaseSensitive;
|
||||
|
||||
assert_eq!(matcher.get_match_type("git", "GIT"), None);
|
||||
assert_eq!(
|
||||
matcher.get_match_type("git", "git"),
|
||||
Some(Match::Exact {
|
||||
is_case_sensitive: true
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
matcher.get_match_type("AsDs", "AsDss"),
|
||||
Some(Match::Prefix {
|
||||
is_case_sensitive: true
|
||||
})
|
||||
);
|
||||
assert_eq!(matcher.get_match_type("Asds", "asds"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_match_type_case_insensitive() {
|
||||
let matcher = MatchStrategy::CaseInsensitive;
|
||||
|
||||
assert_eq!(
|
||||
matcher.get_match_type("git", "GIT"),
|
||||
Some(Match::Exact {
|
||||
is_case_sensitive: false
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
matcher.get_match_type("AsDs", "asdss"),
|
||||
Some(Match::Prefix {
|
||||
is_case_sensitive: false
|
||||
})
|
||||
);
|
||||
assert_eq!(matcher.get_match_type("Asd", "ads"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_match_type_fuzzy() {
|
||||
let matcher = MatchStrategy::Fuzzy;
|
||||
|
||||
assert_eq!(
|
||||
matcher.get_match_type("git", "GIT"),
|
||||
Some(Match::Exact {
|
||||
is_case_sensitive: false
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
matcher.get_match_type("AsDs", "asdss"),
|
||||
Some(Match::Prefix {
|
||||
is_case_sensitive: false
|
||||
})
|
||||
);
|
||||
assert!(matches!(
|
||||
matcher.get_match_type("abc", "aabac"),
|
||||
Some(Match::Fuzzy { .. })
|
||||
));
|
||||
assert_eq!(matcher.get_match_type("abc", "xyz"), None);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
mod coalesce;
|
||||
mod context;
|
||||
mod describe;
|
||||
mod engine;
|
||||
mod matchers;
|
||||
mod suggest;
|
||||
pub use suggest::alias::*;
|
||||
|
||||
#[cfg(feature = "test-util")]
|
||||
pub mod testing;
|
||||
|
||||
pub use context::{
|
||||
CommandExitStatus, CommandOutput, CompletionContext, GeneratorContext, PathCompletionContext,
|
||||
PathSeparators,
|
||||
};
|
||||
pub use describe::{describe, describe_given_token, Description, TopLevelCommandCaseSensitivity};
|
||||
pub use engine::{EngineDirEntry, EngineFileType, LocationType};
|
||||
pub use matchers::{Match, MatchStrategy, MatchType};
|
||||
pub use suggest::{
|
||||
suggestions, CompleterOptions, CompletionsFallbackStrategy, MatchedSuggestion, Priority,
|
||||
Suggestion, SuggestionResults, SuggestionType, SuggestionTypeName,
|
||||
};
|
||||
|
||||
#[cfg(feature = "v2")]
|
||||
pub use context::{JsExecutionContext, JsExecutionError};
|
||||
|
||||
fn get_path_separators(ctx: &dyn CompletionContext) -> PathSeparators {
|
||||
ctx.path_completion_context()
|
||||
.map(|ctx| ctx.path_separators())
|
||||
.unwrap_or(PathSeparators::for_os())
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use async_recursion::async_recursion;
|
||||
use itertools::Itertools;
|
||||
|
||||
use crate::completer::{CompletionContext, TopLevelCommandCaseSensitivity};
|
||||
use crate::meta::Span;
|
||||
use crate::parsers::simple::parse_for_completions;
|
||||
#[cfg(not(feature = "v2"))]
|
||||
use crate::parsers::SignatureAtTokenIndex;
|
||||
use crate::parsers::{classify_command, ClassifiedCommand};
|
||||
#[cfg(feature = "v2")]
|
||||
use crate::signatures::Command;
|
||||
|
||||
/// This is used to limit how many times we re-run the completer once we
|
||||
/// evaluate an alias to prevent infinite recursion.
|
||||
const ALIAS_EXPANSION_MAX_INDIRECTION_LIMIT: usize = 5;
|
||||
|
||||
struct NumAliasExpansionsAttempted(usize);
|
||||
|
||||
impl NumAliasExpansionsAttempted {
|
||||
fn new() -> Self {
|
||||
NumAliasExpansionsAttempted(0)
|
||||
}
|
||||
|
||||
fn increment(self) -> Self {
|
||||
NumAliasExpansionsAttempted(self.0 + 1)
|
||||
}
|
||||
|
||||
fn reached_limit(&self) -> bool {
|
||||
self.0 >= ALIAS_EXPANSION_MAX_INDIRECTION_LIMIT
|
||||
}
|
||||
}
|
||||
|
||||
struct EvaluatedAliases<'a> {
|
||||
pub top_level_aliases: HashSet<&'a str>,
|
||||
pub command_aliases: HashSet<String>,
|
||||
}
|
||||
|
||||
impl EvaluatedAliases<'_> {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
top_level_aliases: HashSet::new(),
|
||||
command_aliases: HashSet::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The result of alias expansion.
|
||||
/// Note that fields other than `expanded_command_line` are specific to completions, which
|
||||
/// throw away info about earlier commands in cases like "command1 && command2", and
|
||||
/// generally should not be used outside the completions engine.
|
||||
pub struct AliasExpansionResult<'a> {
|
||||
/// The entire raw expanded command line after alias expansions.
|
||||
pub expanded_command_line: String,
|
||||
/// The signature we should use for completions after alias expansion.
|
||||
#[cfg(feature = "v2")]
|
||||
pub(crate) signature_for_completions: Option<&'a Command>,
|
||||
#[cfg(not(feature = "v2"))]
|
||||
pub(crate) signature_for_completions: Option<SignatureAtTokenIndex<'a>>,
|
||||
/// The tokens from the expanded_command_line to be used for completions, without any env vars.
|
||||
pub(crate) tokens_from_command: Vec<String>,
|
||||
/// The classified command from expanded_command_line to be used for completions.
|
||||
/// TODO(roland) This should be pub(crate) once command validation handles "command1 && command2" case
|
||||
pub classified_command: Option<ClassifiedCommand>,
|
||||
}
|
||||
|
||||
/// Expands aliases in the `line`, returning a tuple containing `line` with the
|
||||
/// alias replaced along with the alias itself, but ONLY if there is a space after the alias.
|
||||
///
|
||||
/// For example, given alias kgp="kubectl get pod", "kgp" will NOT be expanded, but "kgp " will.
|
||||
///
|
||||
/// TODO(INT-830): handle alias expansion with multiple commands. Current alias expansion logic
|
||||
/// was implemented for completions, where only the last command needs to be expanded.
|
||||
/// For example, given "kgp && kgp ", we expect it to expand to "kubectl get pod && kubectl get pod ",
|
||||
/// but currently it will expand to "kgp && kubectl get pod ".
|
||||
pub async fn expand_command_aliases<'a>(
|
||||
line: &str,
|
||||
parse_quotes_as_literals: bool,
|
||||
ctx: &'a dyn CompletionContext,
|
||||
) -> AliasExpansionResult<'a> {
|
||||
expand_command_aliases_internal(
|
||||
line,
|
||||
&mut EvaluatedAliases::new(),
|
||||
NumAliasExpansionsAttempted::new(),
|
||||
parse_quotes_as_literals,
|
||||
ctx,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[async_recursion]
|
||||
async fn expand_command_aliases_internal<'a>(
|
||||
line: &str,
|
||||
evaluated_aliases: &mut EvaluatedAliases<'a>,
|
||||
num_alias_expansions_attempted: NumAliasExpansionsAttempted,
|
||||
parse_quotes_as_literals: bool,
|
||||
ctx: &'a dyn CompletionContext,
|
||||
) -> AliasExpansionResult<'a> {
|
||||
// Lite command we are completing upon. Note that this includes the full command including
|
||||
// parts like environment variable assignment.
|
||||
let command_to_complete =
|
||||
parse_for_completions(line, ctx.escape_char(), parse_quotes_as_literals)
|
||||
.unwrap_or_default();
|
||||
|
||||
// The vector of tokens in the command. Note that the tokens are modified later to remove
|
||||
// any environment variable assignment token for completion generation.
|
||||
// TODO(kevin): We are using a mutable vector here so we don't need to allocate
|
||||
// multiple times. But this makes the code harder to read. We should think about
|
||||
// a better way to represent it.
|
||||
let mut tokens_from_command = command_to_complete
|
||||
.parts
|
||||
.iter()
|
||||
.map(|s| s.as_str())
|
||||
.collect_vec();
|
||||
|
||||
let classified_command = classify_command(
|
||||
command_to_complete.clone(),
|
||||
&mut tokens_from_command,
|
||||
ctx.command_registry(),
|
||||
ctx.command_case_sensitivity(),
|
||||
);
|
||||
|
||||
let command_name_span = classified_command
|
||||
.as_ref()
|
||||
.map(|command| command.command.name_span());
|
||||
|
||||
let alias_expansion_reached_limit = num_alias_expansions_attempted.reached_limit();
|
||||
if alias_expansion_reached_limit {
|
||||
log::warn!("Alias expansion reached limit!");
|
||||
}
|
||||
if let Some((command_with_expanded_alias, alias)) = command_name_span
|
||||
.and_then(|command_name_span| expand_root_command_alias(line, command_name_span, ctx))
|
||||
{
|
||||
// If the command has an alias that was evaluated, recursively expand more aliases using the new expanded line.
|
||||
if !evaluated_aliases.top_level_aliases.contains(alias) && !alias_expansion_reached_limit {
|
||||
// Ensure we don't evaluate the alias again so we don't end up in an infinite
|
||||
// loop of alias expansion for the same alias name.
|
||||
evaluated_aliases.top_level_aliases.insert(alias);
|
||||
return expand_command_aliases_internal(
|
||||
command_with_expanded_alias.as_str(),
|
||||
evaluated_aliases,
|
||||
num_alias_expansions_attempted.increment(),
|
||||
parse_quotes_as_literals,
|
||||
ctx,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(feature = "v2")] {
|
||||
use crate::signatures::{get_matching_signature_for_input, get_matching_signature_for_tokenized_input};
|
||||
|
||||
let found_signature =
|
||||
// Short-circuit if this alias has already been expanded, or if we've reached
|
||||
// the recursion limit for alias expansion.
|
||||
//
|
||||
// This is a fork of the logic in the `else` branch, which assumes alias expansion
|
||||
// is implemented.
|
||||
if evaluated_aliases.command_aliases.contains(line) || alias_expansion_reached_limit {
|
||||
command_name_span.and_then(|command_name_span| {
|
||||
// We get the line based on the classified command name span.
|
||||
// We use this span because it factors in complicated commands like
|
||||
// "git commit && cd".
|
||||
let command_line = &line[command_name_span.start()..];
|
||||
get_matching_signature_for_input(command_line, ctx.command_registry())
|
||||
}).map(|(signature, _)| signature)
|
||||
} else {
|
||||
// TODO(completions-v2): Implement command-specific alias expansion.
|
||||
get_matching_signature_for_tokenized_input(
|
||||
&tokens_from_command,
|
||||
command_to_complete.post_whitespace.is_some(),
|
||||
ctx.command_registry()).map(|(signature, _)| signature)
|
||||
};
|
||||
} else {
|
||||
use crate::signatures::registry::SignatureResult;
|
||||
|
||||
let found_signature =
|
||||
if evaluated_aliases.command_aliases.contains(line) || alias_expansion_reached_limit {
|
||||
// If we have already expanded the current command alias before or we have reached the
|
||||
// indirection limit, don't consider alias expansion here to avoid infinite looping.
|
||||
command_name_span.and_then(|command_name_span| {
|
||||
// We get the line based on the classified command name span.
|
||||
// We use this span because it factors in complicated commands like
|
||||
// "git commit && cd".
|
||||
let command_line = &line[command_name_span.start()..];
|
||||
ctx.command_registry().signature_from_line(command_line, ctx.command_case_sensitivity())
|
||||
})
|
||||
} else {
|
||||
match ctx
|
||||
.command_registry()
|
||||
.signature_with_alias_expansion(
|
||||
&tokens_from_command,
|
||||
command_to_complete.post_whitespace.is_some(),
|
||||
ctx,
|
||||
)
|
||||
.await
|
||||
{
|
||||
SignatureResult::Success(found_signature) => Some(found_signature),
|
||||
SignatureResult::NeedAliasExpansion(mut new_command) => {
|
||||
// Push an additional empty space
|
||||
if command_to_complete.post_whitespace.is_some() {
|
||||
new_command.push(' ');
|
||||
}
|
||||
// We expanded only tokens_from_command, which did not include any earlier commands and env vars in the line.
|
||||
// Add them back in.
|
||||
if let Some(classified_command) = classified_command {
|
||||
let start_of_command = classified_command.command.name_span().start();
|
||||
if start_of_command > 0 {
|
||||
new_command = format!("{}{}", &line[..start_of_command], new_command);
|
||||
}
|
||||
}
|
||||
evaluated_aliases.command_aliases.insert(line.into());
|
||||
return expand_command_aliases_internal(
|
||||
new_command.as_str(),
|
||||
evaluated_aliases,
|
||||
num_alias_expansions_attempted.increment(),
|
||||
parse_quotes_as_literals,
|
||||
ctx,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
SignatureResult::None => None,
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
AliasExpansionResult {
|
||||
expanded_command_line: line.to_string(),
|
||||
signature_for_completions: found_signature,
|
||||
tokens_from_command: tokens_from_command
|
||||
.into_iter()
|
||||
.map(|s| s.to_owned())
|
||||
.collect_vec(),
|
||||
classified_command,
|
||||
}
|
||||
}
|
||||
|
||||
/// Expands a root command alias in `input`, returning a tuple containing `input` with the
|
||||
/// alias replaced along with the alias itself, but ONLY if there is a space after the alias.
|
||||
///
|
||||
/// For example, given alias kgp="kubectl get pod", "kgp" will NOT be expanded, but "kgp " will.
|
||||
///
|
||||
/// command_name_span contains the span for the root command to check aliases against.
|
||||
/// No other potential aliases in `input` will be checked.
|
||||
fn expand_root_command_alias<'a>(
|
||||
input: &str,
|
||||
command_name_span: Span,
|
||||
context: &'a dyn CompletionContext,
|
||||
) -> Option<(String, &'a str)> {
|
||||
// There must be a space after command_name_span for the alias to be expanded.
|
||||
if input
|
||||
.get(command_name_span.end()..command_name_span.end() + 1)
|
||||
.is_none_or(|s| s != " ")
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let command_span_str = command_name_span.slice(input);
|
||||
context.aliases().find_map(|(alias, value)| {
|
||||
// Check if we have an alias for the command_name_span and a space after it.
|
||||
let matches = if context.alias_and_function_case_sensitivity()
|
||||
== TopLevelCommandCaseSensitivity::CaseInsensitive
|
||||
{
|
||||
alias.eq_ignore_ascii_case(command_span_str)
|
||||
} else {
|
||||
alias == command_span_str
|
||||
};
|
||||
if matches {
|
||||
let before_alias = &input[..command_name_span.start()];
|
||||
let after_alias = &input[command_name_span.end()..];
|
||||
return Some((format!("{before_alias}{value}{after_alias}"), alias));
|
||||
}
|
||||
None
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "alias_test.rs"]
|
||||
mod test;
|
||||
@@ -0,0 +1,320 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use warp_util::path::ShellFamily;
|
||||
|
||||
use crate::completer::expand_command_aliases;
|
||||
use crate::completer::testing::{FakeCompletionContext, MockGeneratorContext};
|
||||
use crate::signatures::testing::{create_test_command_registry, test_signature};
|
||||
|
||||
#[test]
|
||||
pub fn test_expand_command_aliases() {
|
||||
let registry = create_test_command_registry([test_signature()]);
|
||||
|
||||
let generator_ctx = MockGeneratorContext::for_test_signature();
|
||||
let mut aliases = HashMap::new();
|
||||
aliases.insert("aliasForTest".into(), "test".to_owned());
|
||||
let ctx = FakeCompletionContext::new(registry)
|
||||
.with_generator_context(generator_ctx)
|
||||
.with_aliases(aliases);
|
||||
|
||||
// Simple case: there's a command we don't have an alias for
|
||||
let result = warpui::r#async::block_on(expand_command_aliases(
|
||||
"normalCommandWithoutAlias ",
|
||||
false,
|
||||
&ctx,
|
||||
));
|
||||
assert_eq!(result.expanded_command_line, "normalCommandWithoutAlias ");
|
||||
assert_eq!(
|
||||
result.tokens_from_command,
|
||||
vec!["normalCommandWithoutAlias"]
|
||||
);
|
||||
assert!(result.signature_for_completions.is_none());
|
||||
|
||||
// We have a top-level "aliasForTest" which expands to "test".
|
||||
let result = warpui::r#async::block_on(expand_command_aliases("aliasForTest ", false, &ctx));
|
||||
assert_eq!(result.expanded_command_line, "test ");
|
||||
assert_eq!(result.tokens_from_command, vec!["test"]);
|
||||
#[cfg(not(feature = "v2"))]
|
||||
assert_eq!(
|
||||
result
|
||||
.signature_for_completions
|
||||
.expect("should have signature for completions")
|
||||
.signature
|
||||
.name(),
|
||||
"test"
|
||||
);
|
||||
|
||||
#[cfg(not(feature = "v2"))]
|
||||
{
|
||||
// The test signature has an alias function, which expands subcommand "twelve" to "one".
|
||||
let result = warpui::r#async::block_on(expand_command_aliases("test twelve ", false, &ctx));
|
||||
assert_eq!(result.expanded_command_line, "test one ");
|
||||
assert_eq!(result.tokens_from_command, vec!["test", "one"]);
|
||||
// Should be using the subcommand signature for completions
|
||||
assert_eq!(
|
||||
result
|
||||
.signature_for_completions
|
||||
.expect("should have signature for completions")
|
||||
.signature
|
||||
.name(),
|
||||
"one"
|
||||
);
|
||||
|
||||
// We have a top-level aliasForTest which expands to test, and then the test signature expands "twelve" to "one"
|
||||
let result =
|
||||
warpui::r#async::block_on(expand_command_aliases("aliasForTest twelve ", false, &ctx));
|
||||
assert_eq!(result.expanded_command_line, "test one ");
|
||||
assert_eq!(result.tokens_from_command, vec!["test", "one"]);
|
||||
// Should be using the subcommand signature for completions
|
||||
assert_eq!(
|
||||
result
|
||||
.signature_for_completions
|
||||
.expect("should have signature for completions")
|
||||
.signature
|
||||
.name(),
|
||||
"one"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
pub fn test_expand_command_aliases_env_vars() {
|
||||
let registry = create_test_command_registry([test_signature()]);
|
||||
|
||||
let generator_ctx = MockGeneratorContext::for_test_signature();
|
||||
let mut aliases = HashMap::new();
|
||||
aliases.insert("aliasForTest".into(), "test".to_owned());
|
||||
let ctx = FakeCompletionContext::new(registry)
|
||||
.with_generator_context(generator_ctx)
|
||||
.with_aliases(aliases);
|
||||
|
||||
// We have a top-level "aliasForTest" which expands to "test".
|
||||
let result = warpui::r#async::block_on(expand_command_aliases(
|
||||
"ENV1=VAL1 ENV2=VAL2 aliasForTest ",
|
||||
false,
|
||||
&ctx,
|
||||
));
|
||||
assert_eq!(result.expanded_command_line, "ENV1=VAL1 ENV2=VAL2 test ");
|
||||
// Tokens should not include env vars
|
||||
assert_eq!(result.tokens_from_command, vec!["test"]);
|
||||
// Should have env vars in classified command.
|
||||
assert_eq!(
|
||||
result
|
||||
.classified_command
|
||||
.expect("should have classified command")
|
||||
.env_vars,
|
||||
vec!["ENV1=VAL1", "ENV2=VAL2"]
|
||||
);
|
||||
#[cfg(not(feature = "v2"))]
|
||||
assert_eq!(
|
||||
result
|
||||
.signature_for_completions
|
||||
.expect("should have signature for completions")
|
||||
.signature
|
||||
.name(),
|
||||
"test"
|
||||
);
|
||||
|
||||
#[cfg(not(feature = "v2"))]
|
||||
{
|
||||
// The test signature has an alias function, which expands subcommand "twelve" to "one".
|
||||
let result = warpui::r#async::block_on(expand_command_aliases(
|
||||
"ENV1=VAL1 ENV2=VAL2 test twelve ",
|
||||
false,
|
||||
&ctx,
|
||||
));
|
||||
assert_eq!(
|
||||
result.expanded_command_line,
|
||||
"ENV1=VAL1 ENV2=VAL2 test one "
|
||||
);
|
||||
// Tokens should not include env vars
|
||||
assert_eq!(result.tokens_from_command, vec!["test", "one"]);
|
||||
// Should have env vars in classified command.
|
||||
assert_eq!(
|
||||
result
|
||||
.classified_command
|
||||
.expect("should have classified command")
|
||||
.env_vars,
|
||||
vec!["ENV1=VAL1", "ENV2=VAL2"]
|
||||
);
|
||||
// Should be using the subcommand signature for completions
|
||||
assert_eq!(
|
||||
result
|
||||
.signature_for_completions
|
||||
.expect("should have signature for completions")
|
||||
.signature
|
||||
.name(),
|
||||
"one"
|
||||
);
|
||||
|
||||
// We have a top-level aliasForTest which expands to test, and then the test signature expands "twelve" to "one"
|
||||
let result = warpui::r#async::block_on(expand_command_aliases(
|
||||
"ENV1=VAL1 ENV2=VAL2 aliasForTest twelve ",
|
||||
false,
|
||||
&ctx,
|
||||
));
|
||||
assert_eq!(
|
||||
result.expanded_command_line,
|
||||
"ENV1=VAL1 ENV2=VAL2 test one "
|
||||
);
|
||||
// Tokens should not include env vars
|
||||
assert_eq!(result.tokens_from_command, vec!["test", "one"]);
|
||||
// Should have env vars in classified command.
|
||||
assert_eq!(
|
||||
result
|
||||
.classified_command
|
||||
.expect("should have classified command")
|
||||
.env_vars,
|
||||
vec!["ENV1=VAL1", "ENV2=VAL2"]
|
||||
);
|
||||
// Should be using the subcommand signature for completions
|
||||
assert_eq!(
|
||||
result
|
||||
.signature_for_completions
|
||||
.expect("should have signature for completions")
|
||||
.signature
|
||||
.name(),
|
||||
"one"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
pub fn test_expand_command_aliases_should_not_expand_if_no_space_after_alias() {
|
||||
let registry = create_test_command_registry([test_signature()]);
|
||||
|
||||
let generator_ctx = MockGeneratorContext::for_test_signature();
|
||||
let mut aliases = HashMap::new();
|
||||
aliases.insert("aliasForTest".into(), "test".to_owned());
|
||||
let ctx = FakeCompletionContext::new(registry)
|
||||
.with_generator_context(generator_ctx)
|
||||
.with_aliases(aliases);
|
||||
|
||||
// We have a top-level "aliasForTest" which expands to "test", but there's no trailing space so we shouldn't expand.
|
||||
let result = warpui::r#async::block_on(expand_command_aliases("aliasForTest", false, &ctx));
|
||||
assert_eq!(result.expanded_command_line, "aliasForTest");
|
||||
assert_eq!(result.tokens_from_command, vec!["aliasForTest"]);
|
||||
assert!(result.signature_for_completions.is_none());
|
||||
|
||||
// The test signature has an alias function which expands subcommand "twelve" to "one", but there's no trailing space so we shouldn't expand.
|
||||
let result = warpui::r#async::block_on(expand_command_aliases("test twelve", false, &ctx));
|
||||
assert_eq!(result.expanded_command_line, "test twelve");
|
||||
assert_eq!(result.tokens_from_command, vec!["test", "twelve"]);
|
||||
// "twelve" isn't a valid subcommand, so we should use the "test" signature.
|
||||
#[cfg(not(feature = "v2"))]
|
||||
assert_eq!(
|
||||
result
|
||||
.signature_for_completions
|
||||
.expect("should have signature for completions")
|
||||
.signature
|
||||
.name(),
|
||||
"test"
|
||||
);
|
||||
|
||||
// We have a top-level aliasForTest which expands to test. But the test signature does not expand "twelve" to "one" because there's no trailing space.
|
||||
let result =
|
||||
warpui::r#async::block_on(expand_command_aliases("aliasForTest twelve", false, &ctx));
|
||||
assert_eq!(result.expanded_command_line, "test twelve");
|
||||
assert_eq!(result.tokens_from_command, vec!["test", "twelve"]);
|
||||
// "twelve" isn't a valid subcommand, so we should use the "test" signature.
|
||||
#[cfg(not(feature = "v2"))]
|
||||
assert_eq!(
|
||||
result
|
||||
.signature_for_completions
|
||||
.expect("should have signature for completions")
|
||||
.signature
|
||||
.name(),
|
||||
"test"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
pub fn test_expand_command_aliases_case_insensitive_for_powershell() {
|
||||
let registry = create_test_command_registry([test_signature()]);
|
||||
|
||||
let generator_ctx = MockGeneratorContext::for_test_signature();
|
||||
let mut aliases = HashMap::new();
|
||||
aliases.insert("aliasForTest".into(), "test".to_owned());
|
||||
let ctx = FakeCompletionContext::new(registry)
|
||||
.with_generator_context(generator_ctx)
|
||||
.with_aliases(aliases)
|
||||
.with_shell_family(ShellFamily::PowerShell);
|
||||
|
||||
let result = warpui::r#async::block_on(expand_command_aliases("ALIASFORTEST ", false, &ctx));
|
||||
assert_eq!(result.expanded_command_line, "test ");
|
||||
|
||||
let result = warpui::r#async::block_on(expand_command_aliases("aliasfortest ", false, &ctx));
|
||||
assert_eq!(result.expanded_command_line, "test ");
|
||||
|
||||
let result = warpui::r#async::block_on(expand_command_aliases("ALIASFORTEST", false, &ctx));
|
||||
assert_eq!(result.expanded_command_line, "ALIASFORTEST");
|
||||
}
|
||||
|
||||
#[test]
|
||||
pub fn test_expand_command_aliases_case_sensitive_for_posix() {
|
||||
let registry = create_test_command_registry([test_signature()]);
|
||||
|
||||
let generator_ctx = MockGeneratorContext::for_test_signature();
|
||||
let mut aliases = HashMap::new();
|
||||
aliases.insert("aliasForTest".into(), "test".to_owned());
|
||||
let ctx = FakeCompletionContext::new(registry)
|
||||
.with_generator_context(generator_ctx)
|
||||
.with_aliases(aliases)
|
||||
.with_shell_family(ShellFamily::Posix);
|
||||
|
||||
let result = warpui::r#async::block_on(expand_command_aliases("ALIASFORTEST ", false, &ctx));
|
||||
assert_eq!(result.expanded_command_line, "ALIASFORTEST ");
|
||||
|
||||
let result = warpui::r#async::block_on(expand_command_aliases("aliasForTest ", false, &ctx));
|
||||
assert_eq!(result.expanded_command_line, "test ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
pub fn test_expand_command_aliases_multiple_commands() {
|
||||
let registry = create_test_command_registry([test_signature()]);
|
||||
|
||||
let generator_ctx = MockGeneratorContext::for_test_signature();
|
||||
let mut aliases = HashMap::new();
|
||||
aliases.insert("aliasForTest".into(), "test".to_owned());
|
||||
let ctx = FakeCompletionContext::new(registry)
|
||||
.with_generator_context(generator_ctx)
|
||||
.with_aliases(aliases);
|
||||
|
||||
// We have a top-level "aliasForTest" which expands to "test".
|
||||
let result = warpui::r#async::block_on(expand_command_aliases(
|
||||
"kubectl get pod && ENV1=VAL1 ENV2=VAL2 aliasForTest ",
|
||||
false,
|
||||
&ctx,
|
||||
));
|
||||
assert_eq!(
|
||||
result.expanded_command_line,
|
||||
"kubectl get pod && ENV1=VAL1 ENV2=VAL2 test "
|
||||
);
|
||||
|
||||
#[cfg(not(feature = "v2"))]
|
||||
{
|
||||
// The test signature has an alias function, which expands subcommand "twelve" to "one".
|
||||
let result = warpui::r#async::block_on(expand_command_aliases(
|
||||
"kubectl get pod && ENV1=VAL1 ENV2=VAL2 test twelve ",
|
||||
false,
|
||||
&ctx,
|
||||
));
|
||||
assert_eq!(
|
||||
result.expanded_command_line,
|
||||
"kubectl get pod && ENV1=VAL1 ENV2=VAL2 test one "
|
||||
);
|
||||
|
||||
// Multiple commands should all have their aliases expanded.
|
||||
// It is a known issue that only the last command is expanded currently.
|
||||
// TODO(INT-830): fix this case, it should expand to "ENV1=VAL1 ENV2=VAL2 test && ENV3=VAL3 ENV3=VAL3 test "
|
||||
let result = warpui::r#async::block_on(expand_command_aliases(
|
||||
"ENV1=VAL1 ENV2=VAL2 aliasForTest && ENV3=VAL3 ENV3=VAL3 aliasForTest ",
|
||||
false,
|
||||
&ctx,
|
||||
));
|
||||
assert_eq!(
|
||||
result.expanded_command_line,
|
||||
"ENV1=VAL1 ENV2=VAL2 aliasForTest && ENV3=VAL3 ENV3=VAL3 test "
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
//! Contains the implementation of internal suggestion generation logic that's coupled to the
|
||||
//! legacy command signature struct (`crate::signatures::CommandSignature`).
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::completer::{
|
||||
engine::{self, CompletionLocation},
|
||||
suggest::SuggestionTypeName,
|
||||
CompleterOptions, CompletionContext, LocationType, MatchedSuggestion,
|
||||
};
|
||||
use crate::parsers::{ClassifiedCommand, SignatureAtTokenIndex};
|
||||
|
||||
/// Returns a map of `SuggestionType` to vectors of `MatchedSuggestion`s for `line`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) async fn completion_results_from_locations(
|
||||
line: &str,
|
||||
classified_command: Option<ClassifiedCommand>,
|
||||
tokens_from_command: &[&str],
|
||||
found_signature: Option<SignatureAtTokenIndex<'_>>,
|
||||
locations: Vec<CompletionLocation>,
|
||||
session_env_vars: Option<&HashMap<String, String>>,
|
||||
options: &CompleterOptions,
|
||||
context: &dyn CompletionContext,
|
||||
) -> HashMap<SuggestionTypeName, Vec<MatchedSuggestion>> {
|
||||
let mut completion_results_by_type =
|
||||
HashMap::<SuggestionTypeName, Vec<MatchedSuggestion>>::new();
|
||||
|
||||
// For each location type, build up a single completion result for the
|
||||
// corresponding suggestion type.
|
||||
for location in locations {
|
||||
match location.item {
|
||||
LocationType::Command { parsed_token, .. } => {
|
||||
let results =
|
||||
engine::command_suggestions(context, options.match_strategy, &parsed_token)
|
||||
.await;
|
||||
completion_results_by_type
|
||||
.entry(SuggestionTypeName::Command)
|
||||
.or_default()
|
||||
.extend(results);
|
||||
}
|
||||
LocationType::Variable { parsed_token } => {
|
||||
if let Some(env_vars) = context.environment_variable_names() {
|
||||
let results = engine::variable_suggestions(
|
||||
options.match_strategy,
|
||||
env_vars,
|
||||
&parsed_token,
|
||||
);
|
||||
completion_results_by_type
|
||||
.entry(SuggestionTypeName::Variable)
|
||||
.or_default()
|
||||
.extend(results);
|
||||
}
|
||||
}
|
||||
LocationType::Flag { .. } => {
|
||||
let results =
|
||||
engine::flag_suggestions(options.match_strategy, &location, found_signature);
|
||||
completion_results_by_type
|
||||
.entry(SuggestionTypeName::Option)
|
||||
.or_default()
|
||||
.extend(results);
|
||||
}
|
||||
LocationType::Argument {
|
||||
ref parsed_token, ..
|
||||
} => {
|
||||
if let Some(classified_command) = classified_command.as_ref() {
|
||||
let results = engine::argument_suggestions(
|
||||
line,
|
||||
tokens_from_command,
|
||||
classified_command.clone(),
|
||||
found_signature,
|
||||
&location,
|
||||
parsed_token,
|
||||
session_env_vars,
|
||||
options,
|
||||
context,
|
||||
)
|
||||
.await;
|
||||
completion_results_by_type
|
||||
.entry(SuggestionTypeName::Argument)
|
||||
.or_default()
|
||||
.extend(results);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
completion_results_by_type
|
||||
}
|
||||
@@ -0,0 +1,633 @@
|
||||
pub mod alias;
|
||||
#[cfg_attr(feature = "v2", path = "v2.rs")]
|
||||
#[cfg_attr(not(feature = "v2"), path = "legacy.rs")]
|
||||
mod imp;
|
||||
mod priority;
|
||||
use alias::{expand_command_aliases, AliasExpansionResult};
|
||||
pub use priority::Priority;
|
||||
|
||||
use imp::*;
|
||||
use warp_core::ui::theme::AnsiColorIdentifier;
|
||||
|
||||
use std::cmp::Ordering;
|
||||
use std::collections::HashMap;
|
||||
use std::fmt::{self, Display, Formatter};
|
||||
use std::hash::{Hash, Hasher};
|
||||
|
||||
use async_recursion::async_recursion;
|
||||
use itertools::Itertools;
|
||||
use smol_str::SmolStr;
|
||||
use warp_command_signatures::IconType;
|
||||
|
||||
use crate::parsers::simple::parse_for_completions;
|
||||
use crate::{completer::describe::OptionCaseSensitivity, parsers::classify_command};
|
||||
use crate::{completer::TopLevelCommandCaseSensitivity, meta::Span};
|
||||
|
||||
use super::engine::{self, completion_location};
|
||||
use super::{
|
||||
coalesce::coalesce_completion_results,
|
||||
context::CompletionContext,
|
||||
matchers::{Match, MatchStrategy, MatchType},
|
||||
EngineFileType,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct Suggestion {
|
||||
// We store `display` and `replacement` as SmolStr for a few reasons:
|
||||
// 1. They will often be short enough that a heap allocation is not needed,
|
||||
// 2. They get cloned frequently, and clone is O(1) for SmolStr.
|
||||
pub display: SmolStr,
|
||||
pub replacement: SmolStr,
|
||||
|
||||
pub description: Option<String>,
|
||||
pub suggestion_type: SuggestionType,
|
||||
pub override_icon: Option<IconType>,
|
||||
pub priority: Priority,
|
||||
pub is_hidden: bool,
|
||||
/// If Some(), this suggestion is a file/directory.
|
||||
pub file_type: Option<EngineFileType>,
|
||||
/// This field helps us properly describe abbreviations. Normally commands
|
||||
/// are described by matching the replacement string to the token. But
|
||||
/// abbreviations are unique as the replacement string is the expanded form
|
||||
/// of the command, so we need to differentiate them.
|
||||
pub is_abbreviation: bool,
|
||||
}
|
||||
|
||||
impl Suggestion {
|
||||
pub fn new(
|
||||
display_text: impl Into<SmolStr>,
|
||||
replacement_text: impl Into<SmolStr>,
|
||||
description: Option<String>,
|
||||
suggestion_type: SuggestionType,
|
||||
priority: Priority,
|
||||
) -> Self {
|
||||
Self {
|
||||
display: display_text.into(),
|
||||
replacement: replacement_text.into(),
|
||||
description,
|
||||
suggestion_type,
|
||||
priority,
|
||||
override_icon: None,
|
||||
is_hidden: false,
|
||||
file_type: None,
|
||||
is_abbreviation: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a `Suggestion` where the display and replacement are the same.
|
||||
pub fn with_same_display_and_replacement(
|
||||
name: impl Into<SmolStr>,
|
||||
description: Option<String>,
|
||||
suggestion_type: SuggestionType,
|
||||
priority: Priority,
|
||||
) -> Self {
|
||||
let name = name.into();
|
||||
Self {
|
||||
display: name.clone(),
|
||||
replacement: name,
|
||||
description,
|
||||
suggestion_type,
|
||||
priority,
|
||||
override_icon: None,
|
||||
is_hidden: false,
|
||||
file_type: None,
|
||||
is_abbreviation: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_for_abbreviation(
|
||||
display_text: impl Into<SmolStr>,
|
||||
replacement_text: impl Into<SmolStr>,
|
||||
priority: Priority,
|
||||
) -> Self {
|
||||
let replacement_text = replacement_text.into();
|
||||
let description = format!("Abbreviation for \"{replacement_text}\"");
|
||||
Self {
|
||||
display: display_text.into(),
|
||||
replacement: replacement_text,
|
||||
description: Some(description),
|
||||
suggestion_type: SuggestionType::Command(TopLevelCommandCaseSensitivity::CaseSensitive),
|
||||
priority,
|
||||
override_icon: None,
|
||||
is_hidden: false,
|
||||
file_type: None,
|
||||
is_abbreviation: true,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cmp_by_display(&self, other: &Self) -> Ordering {
|
||||
let a = self.display.trim_end_matches(std::path::MAIN_SEPARATOR);
|
||||
let b = other.display.trim_end_matches(std::path::MAIN_SEPARATOR);
|
||||
a.to_lowercase().cmp(&b.to_lowercase()).then(a.cmp(b))
|
||||
}
|
||||
|
||||
/// Note: the ordering here is unconventional. Suggestions with greater
|
||||
/// priorities have a Less Ordering so that when we sort with this fn,
|
||||
/// more important suggestions appear first.
|
||||
pub fn cmp_by_reversed_priority_and_display(&self, other: &Self) -> Ordering {
|
||||
let priority_cmp = self.priority.cmp(&other.priority).reverse();
|
||||
|
||||
// Using then_with here to preempt expensive string comparisons
|
||||
priority_cmp.then_with(|| self.cmp_by_display(other))
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::derived_hash_with_manual_eq)]
|
||||
impl Hash for Suggestion {
|
||||
fn hash<H: Hasher>(&self, state: &mut H) {
|
||||
self.display.hash(state);
|
||||
self.replacement.hash(state)
|
||||
}
|
||||
}
|
||||
|
||||
/// A matched suggestion is what we use to represent a suggestion
|
||||
/// that has been compared against a query. We use this to filter down
|
||||
/// the set of suggestions that the user should see.
|
||||
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
|
||||
pub struct MatchedSuggestion {
|
||||
pub suggestion: Suggestion,
|
||||
pub match_type: Match,
|
||||
}
|
||||
|
||||
/// Wrapper implementation around Suggestion.
|
||||
impl MatchedSuggestion {
|
||||
pub fn new(suggestion: impl Into<Suggestion>, match_type: Match) -> Self {
|
||||
Self {
|
||||
suggestion: suggestion.into(),
|
||||
match_type,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn display(&self) -> &str {
|
||||
self.suggestion.display.as_str()
|
||||
}
|
||||
|
||||
pub fn replacement(&self) -> &str {
|
||||
self.suggestion.replacement.as_str()
|
||||
}
|
||||
|
||||
pub fn description(&self) -> Option<String> {
|
||||
self.suggestion.description.clone()
|
||||
}
|
||||
|
||||
pub fn suggestion_type(&self) -> SuggestionType {
|
||||
self.suggestion.suggestion_type
|
||||
}
|
||||
|
||||
pub fn priority(&self) -> Priority {
|
||||
self.suggestion.priority
|
||||
}
|
||||
|
||||
pub fn is_abbreviation(&self) -> bool {
|
||||
self.suggestion.is_abbreviation
|
||||
}
|
||||
|
||||
/// Helper methods to call into Suggestion comparisons
|
||||
pub fn cmp_by_display(&self, other: &Self) -> Ordering {
|
||||
Suggestion::cmp_by_display(&self.suggestion, &other.suggestion)
|
||||
}
|
||||
|
||||
pub fn cmp_by_reversed_priority_and_display(&self, other: &Self) -> Ordering {
|
||||
Suggestion::cmp_by_reversed_priority_and_display(&self.suggestion, &other.suggestion)
|
||||
}
|
||||
}
|
||||
|
||||
/// While commands in the POSIX world require their option names to be spelled out in full,
|
||||
/// PowerShell cmdlets do not require this. This enum indicates these behaviors.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum MatchRequirement {
|
||||
/// For an option to be recognized, its whole name must be spelled out.
|
||||
EntireName,
|
||||
/// This variant signifies [`warp_command_signatures::ParserDirectives::flags_match_unique_prefix`]
|
||||
/// being `true`. Only a prefix which is long enough to make the intended option unambiguous is
|
||||
/// needed.
|
||||
UniquePrefixOnly,
|
||||
}
|
||||
|
||||
/// Variants point 1:1 to the variants of [`SuggestionType`]. Used for hashing/sorting suggestion
|
||||
/// types.
|
||||
#[derive(Clone, Copy, Debug, Hash, PartialOrd, Ord, PartialEq, Eq)]
|
||||
pub enum SuggestionTypeName {
|
||||
Command = 1,
|
||||
Variable = 2,
|
||||
Argument = 3,
|
||||
Subcommand = 4,
|
||||
Option = 5,
|
||||
}
|
||||
|
||||
impl Display for SuggestionTypeName {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"{}",
|
||||
match self {
|
||||
Self::Command => "Command",
|
||||
Self::Subcommand => "Subcommand",
|
||||
Self::Argument => "Argument",
|
||||
Self::Option => "Option",
|
||||
Self::Variable => "Variable",
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Used for the syntax highlighting use-case. This maps different command parts
|
||||
/// to different colors, as appropriate.
|
||||
impl From<SuggestionTypeName> for AnsiColorIdentifier {
|
||||
fn from(suggestion: SuggestionTypeName) -> Self {
|
||||
match suggestion {
|
||||
SuggestionTypeName::Command => Self::Green,
|
||||
SuggestionTypeName::Subcommand => Self::Blue,
|
||||
SuggestionTypeName::Variable => Self::Magenta,
|
||||
SuggestionTypeName::Argument => Self::Cyan,
|
||||
SuggestionTypeName::Option => Self::Yellow,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Differentiates the types of suggestions and contains any data specific to that type. For
|
||||
/// example, [`MatchRequirement`] only applies to [`SuggestionType::Option`].
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum SuggestionType {
|
||||
Command(TopLevelCommandCaseSensitivity),
|
||||
Variable,
|
||||
Argument,
|
||||
Subcommand,
|
||||
Option(MatchRequirement, OptionCaseSensitivity),
|
||||
}
|
||||
|
||||
impl SuggestionType {
|
||||
pub fn to_name(&self) -> SuggestionTypeName {
|
||||
match self {
|
||||
Self::Command(_) => SuggestionTypeName::Command,
|
||||
Self::Variable => SuggestionTypeName::Variable,
|
||||
Self::Argument => SuggestionTypeName::Argument,
|
||||
Self::Subcommand => SuggestionTypeName::Subcommand,
|
||||
Self::Option(..) => SuggestionTypeName::Option,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct SuggestionResults {
|
||||
pub replacement_span: Span,
|
||||
pub suggestions: Vec<MatchedSuggestion>,
|
||||
pub match_strategy: MatchStrategy,
|
||||
}
|
||||
|
||||
pub struct FilteredSuggestion<'a> {
|
||||
pub suggestion: &'a Suggestion,
|
||||
pub match_type: MatchType,
|
||||
/// The indices of the matching characters between suggestion.display and
|
||||
/// the query that this FilteredSuggestion is derived from.
|
||||
pub matching_indices: Vec<usize>,
|
||||
}
|
||||
|
||||
impl SuggestionResults {
|
||||
/// Orders the suggestions in the following order:
|
||||
/// 1. A suggestion that matches the query exactly (if any)
|
||||
/// 2. Prefix suggestions (with the intelligent ordering preserved)
|
||||
/// 3. Fuzzy suggestions (which are not a prefix match, ordered by fuzzy score)
|
||||
pub fn filter_by_query(
|
||||
&self,
|
||||
query: &str,
|
||||
path_separators: &[char],
|
||||
) -> impl Iterator<Item = FilteredSuggestion<'_>> + '_ {
|
||||
// We build up the suggestions to avoid having to iterate over the
|
||||
// same set of suggestions multiple times. This is performance-sensitive code.
|
||||
// Note that the suggestions in these sets are mutually exclusive.
|
||||
let mut exact_match_suggestion: Option<(Match, &Suggestion)> = None;
|
||||
let mut case_insensitive_exact_match_suggestion: Option<(Match, &Suggestion)> = None;
|
||||
let mut prefix_suggestions = vec![];
|
||||
let mut fuzzy_suggestions = vec![];
|
||||
|
||||
// TODO: In the future, we won't be including the entire filepath
|
||||
// in the replacement and this code will have to change accordingly.
|
||||
// This is to figure out if the query matches the replacement, and if
|
||||
// not, only take the part after the last slash. We are doing this here
|
||||
// to avoid calling count() on each suggestion, which is linear time.
|
||||
// e.g. If query is "a/b/c" then query_len will be 1 since we only take
|
||||
// "c" because it's after the last slash and it is 1 character long.
|
||||
let original_query_len = query.chars().count();
|
||||
let file_query = query
|
||||
.rsplit_once(path_separators)
|
||||
.map_or(query, |(_, after_last_slash)| after_last_slash);
|
||||
let file_query_len = file_query.chars().count();
|
||||
|
||||
for suggestion in self.suggestions.iter() {
|
||||
// This is a very ad-hoc way of overcoming the problem of the query containing
|
||||
// the entire filepath (i.e. `app/src/platform.rs` as opposed to just the final `platform.rs`).
|
||||
// The reason we need to change the query is that we still want to use `suggestion.display()`
|
||||
// since that will yield the best fuzzy scores (vs. a large prefix matching). However,
|
||||
// we still compare the whole query to the replacement to see if there is a match at all to begin with.
|
||||
let query_for_suggestion = if suggestion.suggestion.file_type.is_some() {
|
||||
if self
|
||||
.match_strategy
|
||||
.get_match_type(query, suggestion.replacement())
|
||||
.is_none()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
file_query
|
||||
} else {
|
||||
query
|
||||
};
|
||||
|
||||
if let Some(match_type) = self
|
||||
.match_strategy
|
||||
.get_match_type(query_for_suggestion, suggestion.display())
|
||||
{
|
||||
// If the suggestion is hidden, we should only show it if it's an exact match.
|
||||
if suggestion.suggestion.is_hidden && !matches!(match_type, Match::Exact { .. }) {
|
||||
continue;
|
||||
}
|
||||
|
||||
match match_type {
|
||||
Match::Exact {
|
||||
is_case_sensitive: true,
|
||||
} if exact_match_suggestion.is_none() => {
|
||||
// If the suggestion matches the query exactly, we treat it specially
|
||||
// since we want to order this suggestion first. There should only be one such suggestion.
|
||||
exact_match_suggestion = Some((
|
||||
Match::Exact {
|
||||
is_case_sensitive: true,
|
||||
},
|
||||
&suggestion.suggestion,
|
||||
));
|
||||
}
|
||||
Match::Exact {
|
||||
is_case_sensitive: false,
|
||||
} if case_insensitive_exact_match_suggestion.is_none() => {
|
||||
case_insensitive_exact_match_suggestion = Some((
|
||||
Match::Exact {
|
||||
is_case_sensitive: false,
|
||||
},
|
||||
&suggestion.suggestion,
|
||||
));
|
||||
}
|
||||
Match::Prefix { is_case_sensitive } => prefix_suggestions
|
||||
.push((Match::Prefix { is_case_sensitive }, &suggestion.suggestion)),
|
||||
Match::Fuzzy { ref match_result } => {
|
||||
// Note that if display and replacement differ, then this could
|
||||
// produce the wrong set of matching indices depending on query.
|
||||
// An example of this is filepaths, which we special-case above.
|
||||
let score = match_result.score;
|
||||
fuzzy_suggestions.push((match_type, &suggestion.suggestion, score));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
exact_match_suggestion
|
||||
.into_iter()
|
||||
.chain(case_insensitive_exact_match_suggestion)
|
||||
.chain(prefix_suggestions)
|
||||
.chain(
|
||||
fuzzy_suggestions
|
||||
.into_iter()
|
||||
.sorted_by_key(|(_, _, score)| *score)
|
||||
.rev()
|
||||
.map(|(match_type, matched_suggestion, _)| (match_type, matched_suggestion)),
|
||||
)
|
||||
.map(move |(match_type, suggestion)| {
|
||||
let telemetry_match_type = match_type.clone().into();
|
||||
match match_type {
|
||||
Match::Prefix { .. } | Match::Exact { .. } => {
|
||||
// Similar to above, we should use the appropriate length
|
||||
// depending on which query we used (which is dependent on
|
||||
// whether the suggestion is a file path or not).
|
||||
let len = if suggestion.file_type.is_some() {
|
||||
file_query_len
|
||||
} else {
|
||||
original_query_len
|
||||
};
|
||||
let matching_indices = (0..len).collect();
|
||||
FilteredSuggestion {
|
||||
suggestion,
|
||||
matching_indices,
|
||||
match_type: telemetry_match_type,
|
||||
}
|
||||
}
|
||||
Match::Fuzzy { match_result } => FilteredSuggestion {
|
||||
suggestion,
|
||||
matching_indices: match_result.matched_indices,
|
||||
match_type: telemetry_match_type,
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns a `MatchedSuggestion` if there is a _single_ prefix suggestion, otherwise returns
|
||||
/// `None`.
|
||||
pub fn single_prefix_suggestion(&self) -> Option<&MatchedSuggestion> {
|
||||
let (
|
||||
num_prefix_suggestions,
|
||||
num_case_insensitive_prefix_suggestions,
|
||||
last_prefix_suggestion,
|
||||
last_case_insensitive_prefix_suggestion,
|
||||
) = self.suggestions.iter().fold(
|
||||
(0, 0, None, None),
|
||||
|(num_items, num_case_insensitive_items, suggestion, case_insensitive_suggestion),
|
||||
item| {
|
||||
match item.match_type {
|
||||
// We don't care about distinguishing proper prefixes here.
|
||||
Match::Prefix {
|
||||
is_case_sensitive: true,
|
||||
}
|
||||
| Match::Exact {
|
||||
is_case_sensitive: true,
|
||||
} => (
|
||||
num_items + 1,
|
||||
num_case_insensitive_items,
|
||||
Some(item),
|
||||
case_insensitive_suggestion,
|
||||
),
|
||||
Match::Prefix {
|
||||
is_case_sensitive: false,
|
||||
}
|
||||
| Match::Exact {
|
||||
is_case_sensitive: false,
|
||||
} => (
|
||||
num_items,
|
||||
num_case_insensitive_items + 1,
|
||||
suggestion,
|
||||
Some(item),
|
||||
),
|
||||
_ => (
|
||||
num_items,
|
||||
num_case_insensitive_items,
|
||||
suggestion,
|
||||
case_insensitive_suggestion,
|
||||
),
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
if num_prefix_suggestions == 1 {
|
||||
last_prefix_suggestion
|
||||
} else if num_prefix_suggestions == 0 && num_case_insensitive_prefix_suggestions == 1 {
|
||||
last_case_insensitive_prefix_suggestion
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// In the cases where we don't have completions to show, we can potentially
|
||||
/// fallback to one of these types.
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
pub enum CompletionsFallbackStrategy {
|
||||
FilePaths,
|
||||
None,
|
||||
}
|
||||
|
||||
/// Options struct passed to public completer APIs to configure completions logic.
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
pub struct CompleterOptions {
|
||||
/// The match strategy that should be used to filter suggestions.
|
||||
pub match_strategy: MatchStrategy,
|
||||
|
||||
/// The fallback strategy used to generate completion suggestions when no `CommandSignature`
|
||||
/// exists for the command.
|
||||
pub fallback_strategy: CompletionsFallbackStrategy,
|
||||
|
||||
/// If true, we suggest file paths and nothing else.
|
||||
pub suggest_file_path_completions_only: bool,
|
||||
|
||||
/// If true, we treat quotes as plain literals. Otherwise contents between opening and closing quotes
|
||||
/// will be considered as a single token.
|
||||
pub parse_quotes_as_literals: bool,
|
||||
}
|
||||
|
||||
impl Default for CompleterOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
match_strategy: MatchStrategy::Fuzzy,
|
||||
fallback_strategy: CompletionsFallbackStrategy::FilePaths,
|
||||
suggest_file_path_completions_only: false,
|
||||
parse_quotes_as_literals: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// This is the public API for using Warp's completion engine. Note that
|
||||
/// the completion engines could end up performing I/O (e.g. calling generators,
|
||||
/// interacting with the file system, etc.), so you should ensure that you
|
||||
/// are on a background thread when using this API.
|
||||
pub async fn suggestions<T: CompletionContext>(
|
||||
line: &str,
|
||||
pos: usize,
|
||||
session_env_vars: Option<&HashMap<String, String>>,
|
||||
options: CompleterOptions,
|
||||
ctx: &T,
|
||||
) -> Option<SuggestionResults> {
|
||||
let line = &line[0..pos];
|
||||
if line.trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
suggestions_internal(line, session_env_vars, &options, ctx).await
|
||||
}
|
||||
|
||||
/// Produces `SuggestionResults` with the `replacement_span` if specified. If not specified,
|
||||
/// the `replacement_span` is the value directly from the completer.
|
||||
#[async_recursion]
|
||||
async fn suggestions_internal<'a>(
|
||||
line: &str,
|
||||
session_env_vars: Option<&'a HashMap<String, String>>,
|
||||
options: &CompleterOptions,
|
||||
ctx: &'a dyn CompletionContext,
|
||||
) -> Option<SuggestionResults> {
|
||||
// Lite command we are completing upon. Note that this includes the full command including
|
||||
// parts like environment variable assignment.
|
||||
let command_to_complete =
|
||||
parse_for_completions(line, ctx.escape_char(), options.parse_quotes_as_literals)
|
||||
.unwrap_or_default();
|
||||
|
||||
// The vector of tokens in the command. Note that the tokens are modified later to remove
|
||||
// any environment variable assignment token for completion generation.
|
||||
// TODO(kevin): We are using a mutable vector here so we don't need to allocate
|
||||
// multiple times. But this makes the code harder to read. We should think about
|
||||
// a better way to represent it.
|
||||
let mut tokens_from_command = command_to_complete
|
||||
.parts
|
||||
.iter()
|
||||
.map(|s| s.as_str())
|
||||
.collect_vec();
|
||||
|
||||
let classified_command = classify_command(
|
||||
command_to_complete.clone(),
|
||||
&mut tokens_from_command,
|
||||
ctx.command_registry(),
|
||||
ctx.command_case_sensitivity(),
|
||||
);
|
||||
|
||||
let locations = completion_location(ctx, line, classified_command.as_ref());
|
||||
|
||||
// If there are no completion locations, short-circuit.
|
||||
let replacement_span = locations.last()?.span;
|
||||
|
||||
if options.suggest_file_path_completions_only {
|
||||
let path_completion_context = ctx.path_completion_context()?;
|
||||
let classified_command = &classified_command?;
|
||||
let path_completions = engine::path::sorted_paths_relative_to(
|
||||
&classified_command.command.last_token(),
|
||||
options.match_strategy,
|
||||
path_completion_context,
|
||||
)
|
||||
.await;
|
||||
if path_completions.is_empty() {
|
||||
return None;
|
||||
}
|
||||
return Some(SuggestionResults {
|
||||
suggestions: path_completions,
|
||||
replacement_span,
|
||||
match_strategy: options.match_strategy,
|
||||
});
|
||||
}
|
||||
|
||||
// Expand the line using any top level aliases or command-specific aliases.
|
||||
let AliasExpansionResult {
|
||||
expanded_command_line,
|
||||
signature_for_completions,
|
||||
tokens_from_command,
|
||||
classified_command,
|
||||
} = expand_command_aliases(line, options.parse_quotes_as_literals, ctx).await;
|
||||
// After expanding the line, reparse the expanded command.
|
||||
// We had to parse before alias expansion in order to get the correct replacement span.
|
||||
|
||||
let locations = completion_location(ctx, &expanded_command_line, classified_command.as_ref());
|
||||
|
||||
// Get a single completion result for each corresponding suggestion type.
|
||||
let completion_results_by_type = completion_results_from_locations(
|
||||
&expanded_command_line,
|
||||
classified_command,
|
||||
&tokens_from_command
|
||||
.iter()
|
||||
.map(|s| s.as_str())
|
||||
.collect::<Vec<&str>>(),
|
||||
signature_for_completions,
|
||||
locations,
|
||||
session_env_vars,
|
||||
options,
|
||||
ctx,
|
||||
)
|
||||
.await;
|
||||
|
||||
let suggestions = coalesce_completion_results(completion_results_by_type);
|
||||
|
||||
// Although we don't order the exact match and fuzzy completions here,
|
||||
// we do order them in filter_by_query which we run right after running the
|
||||
// completer and when the user types to filter.
|
||||
// TODO: perform the ordering here and add a check in filter_by_query
|
||||
// to prevent re-ordering again if the query hasn't changed.
|
||||
Some(SuggestionResults {
|
||||
suggestions,
|
||||
replacement_span,
|
||||
match_strategy: options.match_strategy,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "test.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,99 @@
|
||||
//! This module contains the `Priority` struct, which may be specified on [`Suggestion`]s to
|
||||
//! influence the order of suggestions returned to users.
|
||||
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(feature = "v2")] {
|
||||
mod v2;
|
||||
}
|
||||
}
|
||||
|
||||
use std::cmp::Ordering;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// The lowest number we can represent for a Priority.
|
||||
const MIN_PRIORITY: i32 = -100;
|
||||
/// We default to this Priority value if it is not otherwise provided.
|
||||
const DEFAULT_PRIORITY: i32 = 0;
|
||||
/// The highest number we can represent for a Priority.
|
||||
const MAX_PRIORITY: i32 = 100;
|
||||
|
||||
/// Priority is part of how we rank completion suggestions. For non-default priority values, we
|
||||
/// break ties with lexicographic ordering. Higher values are higher priority and appear earlier in
|
||||
/// lists of suggestsions.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Priority(i32);
|
||||
|
||||
impl Priority {
|
||||
/// Creates a new Priority with its value clamped to the range [-100, 100].
|
||||
pub fn new(value: i32) -> Self {
|
||||
Self(value.clamp(MIN_PRIORITY, MAX_PRIORITY))
|
||||
}
|
||||
|
||||
pub fn value(&self) -> i32 {
|
||||
self.0
|
||||
}
|
||||
|
||||
pub fn min() -> Self {
|
||||
Self::new(MIN_PRIORITY)
|
||||
}
|
||||
|
||||
pub fn max() -> Self {
|
||||
Self::new(MAX_PRIORITY)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Priority {
|
||||
fn default() -> Self {
|
||||
Self::new(DEFAULT_PRIORITY)
|
||||
}
|
||||
}
|
||||
|
||||
impl Ord for Priority {
|
||||
fn cmp(&self, other: &Self) -> Ordering {
|
||||
self.0.cmp(&other.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialOrd for Priority {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
|
||||
Some(self.cmp(other))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<warp_command_signatures::Priority> for Priority {
|
||||
fn from(value: warp_command_signatures::Priority) -> Self {
|
||||
match value {
|
||||
warp_command_signatures::Priority::Global(importance)
|
||||
| warp_command_signatures::Priority::Local(importance) => match importance {
|
||||
warp_command_signatures::Importance::More(order) => Self::new(order.0 as i32),
|
||||
warp_command_signatures::Importance::Less(order) => {
|
||||
Self::new(-(101 - order.0 as i32))
|
||||
}
|
||||
},
|
||||
warp_command_signatures::Priority::Default => Priority::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Priority> for warp_command_signatures::Priority {
|
||||
fn from(value: Priority) -> Self {
|
||||
match value.cmp(&Priority::default()) {
|
||||
Ordering::Less => warp_command_signatures::Priority::Global(
|
||||
warp_command_signatures::Importance::Less(warp_command_signatures::Order(
|
||||
101 - value.value().unsigned_abs(),
|
||||
)),
|
||||
),
|
||||
Ordering::Equal => warp_command_signatures::Priority::default(),
|
||||
Ordering::Greater => warp_command_signatures::Priority::Global(
|
||||
warp_command_signatures::Importance::More(warp_command_signatures::Order(
|
||||
value.value().unsigned_abs(),
|
||||
)),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "priority_test.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,118 @@
|
||||
use super::{Priority, MAX_PRIORITY, MIN_PRIORITY};
|
||||
|
||||
#[test]
|
||||
fn test_priority_normalization() {
|
||||
let too_small = Priority::new(-201);
|
||||
assert_eq!(Priority::min(), too_small);
|
||||
|
||||
let too_large = Priority::new(201);
|
||||
assert_eq!(Priority::max(), too_large);
|
||||
|
||||
let fourty_two = Priority::new(42);
|
||||
assert_eq!(42, fourty_two.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_priority_comparison() {
|
||||
let super_important = Priority::new(100);
|
||||
let important = Priority::new(20);
|
||||
let not_important = Priority::new(-60);
|
||||
let default = Priority::default();
|
||||
|
||||
assert!(super_important == super_important);
|
||||
assert!(super_important > important);
|
||||
assert!(super_important > default);
|
||||
assert!(super_important > not_important);
|
||||
|
||||
assert!(important < super_important);
|
||||
assert!(important == important);
|
||||
assert!(important > default);
|
||||
assert!(important > not_important);
|
||||
|
||||
assert!(not_important < super_important);
|
||||
assert!(not_important < important);
|
||||
assert!(not_important < default);
|
||||
assert!(not_important == not_important);
|
||||
|
||||
assert!(default == default);
|
||||
}
|
||||
|
||||
/// Test that we can correctly convert from the new Priority to the original as defined in
|
||||
/// `warp_command_signatures`.
|
||||
#[test]
|
||||
fn test_new_to_old_priority() {
|
||||
use warp_command_signatures::{Importance, Order, Priority as OldPriority};
|
||||
assert_eq!(
|
||||
OldPriority::from(Priority::new(MIN_PRIORITY)),
|
||||
OldPriority::Global(Importance::Less(Order(1))),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
OldPriority::from(Priority::new(-51)),
|
||||
OldPriority::Global(Importance::Less(Order(50))),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
OldPriority::from(Priority::new(-1)),
|
||||
OldPriority::Global(Importance::Less(Order(100)))
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
OldPriority::from(Priority::default()),
|
||||
OldPriority::default()
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
OldPriority::from(Priority::new(1)),
|
||||
OldPriority::Global(Importance::More(Order(1))),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
OldPriority::from(Priority::new(50)),
|
||||
OldPriority::Global(Importance::More(Order(50))),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
OldPriority::from(Priority::new(MAX_PRIORITY)),
|
||||
OldPriority::Global(Importance::More(Order(100))),
|
||||
);
|
||||
}
|
||||
|
||||
/// Test that we can correctly convert from the old Priority as definined in
|
||||
/// `warp_command_signatures` to the new Priority.
|
||||
#[test]
|
||||
fn test_old_to_new_priority() {
|
||||
use warp_command_signatures::{Importance, Order, Priority as OldPriority};
|
||||
|
||||
assert_eq!(
|
||||
Priority::from(OldPriority::Global(Importance::Less(Order(1)))),
|
||||
Priority::new(MIN_PRIORITY)
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
Priority::from(OldPriority::Global(Importance::Less(Order(50)))),
|
||||
Priority::new(-51)
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
Priority::from(OldPriority::Global(Importance::Less(Order(100)))),
|
||||
Priority::new(-1)
|
||||
);
|
||||
|
||||
assert_eq!(Priority::from(OldPriority::Default), Priority::default());
|
||||
|
||||
assert_eq!(
|
||||
Priority::from(OldPriority::Global(Importance::More(Order(1)))),
|
||||
Priority::new(1)
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
Priority::from(OldPriority::Global(Importance::More(Order(50)))),
|
||||
Priority::new(50)
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
Priority::from(OldPriority::Global(Importance::More(Order(100)))),
|
||||
Priority::new(MAX_PRIORITY)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
use super::Priority;
|
||||
|
||||
impl From<Priority> for crate::signatures::Priority {
|
||||
fn from(value: Priority) -> Self {
|
||||
Self::new(value.value())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<crate::signatures::Priority> for Priority {
|
||||
fn from(value: crate::signatures::Priority) -> Self {
|
||||
Self::new(value.value())
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,92 @@
|
||||
//! Contains the v2 implementation of internal suggestion generation logic that depends on the new
|
||||
//! JS-compatible command signatures struct (crate::signatures::CommandSignature).
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::completer::engine::{self, CompletionLocation};
|
||||
use crate::completer::LocationType;
|
||||
use crate::{parsers::ClassifiedCommand, signatures::Command};
|
||||
|
||||
use super::{CompleterOptions, CompletionContext, MatchedSuggestion, SuggestionTypeName};
|
||||
|
||||
/// Returns a map of `SuggestionType` to vectors of `MatchedSuggestion`s for `input`.
|
||||
///
|
||||
/// For every given `SuggestionType` in `suggestion_types_to_complete_on`, corresponding suggestions
|
||||
/// of that type (e.g. flag, argument, command) are generated by suggestion-type-specific
|
||||
/// functions, e.g. `engine::argument_suggestions()`.
|
||||
///
|
||||
/// This is a fork of `super::legacy::completion_results_from_locations` that is compatible with V2
|
||||
/// the Command Signatures struct.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) async fn completion_results_from_locations<'a>(
|
||||
input: &str,
|
||||
classified_command: Option<ClassifiedCommand>,
|
||||
tokens_without_last_editing: &[&str],
|
||||
found_signature: Option<&'a Command>,
|
||||
locations: Vec<CompletionLocation>,
|
||||
session_env_vars: Option<&HashMap<String, String>>,
|
||||
options: &CompleterOptions,
|
||||
ctx: &'a dyn CompletionContext,
|
||||
) -> HashMap<SuggestionTypeName, Vec<MatchedSuggestion>> {
|
||||
let mut completion_results_by_type =
|
||||
HashMap::<SuggestionTypeName, Vec<MatchedSuggestion>>::new();
|
||||
|
||||
// For each location type, build up a single completion result for the
|
||||
// corresponding suggestion type.
|
||||
for location in locations {
|
||||
match location.item {
|
||||
LocationType::Command { parsed_token, .. } => {
|
||||
let results =
|
||||
engine::command_suggestions(ctx, options.match_strategy, &parsed_token).await;
|
||||
completion_results_by_type
|
||||
.entry(SuggestionTypeName::Command)
|
||||
.or_default()
|
||||
.extend(results);
|
||||
}
|
||||
LocationType::Variable { parsed_token } => {
|
||||
if let Some(env_vars) = ctx.environment_variable_names() {
|
||||
let results = engine::variable_suggestions(
|
||||
options.match_strategy,
|
||||
env_vars,
|
||||
&parsed_token,
|
||||
);
|
||||
completion_results_by_type
|
||||
.entry(SuggestionTypeName::Variable)
|
||||
.or_default()
|
||||
.extend(results);
|
||||
}
|
||||
}
|
||||
LocationType::Flag { .. } => {
|
||||
let results =
|
||||
engine::flag_suggestions(options.match_strategy, &location, found_signature);
|
||||
completion_results_by_type
|
||||
.entry(SuggestionTypeName::Option)
|
||||
.or_default()
|
||||
.extend(results);
|
||||
}
|
||||
LocationType::Argument {
|
||||
ref parsed_token, ..
|
||||
} => {
|
||||
if let Some(classified_command) = classified_command.as_ref() {
|
||||
let results = engine::argument_suggestions(
|
||||
input,
|
||||
tokens_without_last_editing,
|
||||
classified_command.clone(),
|
||||
found_signature,
|
||||
&location,
|
||||
session_env_vars,
|
||||
parsed_token,
|
||||
options,
|
||||
ctx,
|
||||
)
|
||||
.await;
|
||||
completion_results_by_type
|
||||
.entry(SuggestionTypeName::Argument)
|
||||
.or_default()
|
||||
.extend(results);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
completion_results_by_type
|
||||
}
|
||||
@@ -0,0 +1,432 @@
|
||||
//! This module contains test-only APIs and utils for testing the completions engine.
|
||||
#[cfg(feature = "v2")]
|
||||
mod v2;
|
||||
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
ops::Deref,
|
||||
path::PathBuf,
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use smol_str::SmolStr;
|
||||
use typed_path::{TypedPath, TypedPathBuf};
|
||||
use warp_command_signatures::IconType;
|
||||
use warp_core::command::ExitCode;
|
||||
use warp_util::path::{EscapeChar, ShellFamily, TEST_SESSION_HOME_DIR};
|
||||
|
||||
use crate::{
|
||||
completer::{
|
||||
CommandOutput, CompletionContext, Description, EngineDirEntry, EngineFileType,
|
||||
GeneratorContext, PathCompletionContext, Suggestion, TopLevelCommandCaseSensitivity,
|
||||
},
|
||||
signatures::{
|
||||
testing::{TEST_ALIAS_COMMAND, TEST_GENERATOR_1_COMMAND, TEST_GENERATOR_2_COMMAND},
|
||||
CommandRegistry,
|
||||
},
|
||||
};
|
||||
|
||||
use super::{CommandExitStatus, MatchedSuggestion, PathSeparators};
|
||||
|
||||
impl EngineDirEntry {
|
||||
pub fn test_file(file_name: &str) -> Self {
|
||||
EngineDirEntry {
|
||||
file_name: file_name.to_owned(),
|
||||
file_type: EngineFileType::File,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn test_dir(file_name: &str) -> Self {
|
||||
EngineDirEntry {
|
||||
file_name: file_name.to_owned(),
|
||||
file_type: EngineFileType::Directory,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Description {
|
||||
pub fn into_token_name(self) -> String {
|
||||
self.token.item
|
||||
}
|
||||
}
|
||||
|
||||
impl Suggestion {
|
||||
pub fn with_icon_override(mut self, icon_type: IconType) -> Self {
|
||||
self.override_icon = Some(icon_type);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_file_type(mut self, file_type: EngineFileType) -> Self {
|
||||
self.file_type = Some(file_type);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl MatchedSuggestion {
|
||||
pub fn with_file_type(mut self, file_type: EngineFileType) -> Self {
|
||||
self.suggestion = self.suggestion.with_file_type(file_type);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_replacement(mut self, replacement: impl Into<SmolStr>) -> Self {
|
||||
self.suggestion.replacement = replacement.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// A mock `GeneratorContext` implementation that allows callers to specify commands that may be
|
||||
/// run (as part of completions generator/alias execution) and their outputs.
|
||||
#[derive(Default)]
|
||||
pub struct MockGeneratorContext {
|
||||
expected_commands_to_output: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl MockGeneratorContext {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
expected_commands_to_output: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a generator context that expects generator/alias commands used by the test command
|
||||
/// signature (`test_signature()`).
|
||||
pub fn for_test_signature() -> Self {
|
||||
Self::new()
|
||||
.with_expected_command(TEST_ALIAS_COMMAND, "alias")
|
||||
.with_expected_command(TEST_GENERATOR_1_COMMAND, "1")
|
||||
.with_expected_command(TEST_GENERATOR_2_COMMAND, "2")
|
||||
}
|
||||
|
||||
pub fn with_expected_command(
|
||||
mut self,
|
||||
command: impl Into<String>,
|
||||
output: impl Into<String>,
|
||||
) -> Self {
|
||||
self.expected_commands_to_output
|
||||
.insert(command.into(), output.into());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl GeneratorContext for MockGeneratorContext {
|
||||
async fn execute_command_at_pwd(
|
||||
&self,
|
||||
shell_command: &str,
|
||||
_session_env_vars: Option<HashMap<String, String>>,
|
||||
) -> anyhow::Result<CommandOutput> {
|
||||
Ok(CommandOutput {
|
||||
stdout: self
|
||||
.expected_commands_to_output
|
||||
.get(shell_command)
|
||||
.expect(
|
||||
"Generator command expectation should have been set on TestGeneratorContext.",
|
||||
)
|
||||
.clone()
|
||||
.into_bytes(),
|
||||
stderr: Vec::new(),
|
||||
status: CommandExitStatus::Success,
|
||||
exit_code: Some(ExitCode::from(0)),
|
||||
})
|
||||
}
|
||||
|
||||
fn supports_parallel_execution(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// A mock `PathCompletionContext` implementation that allows callers to specify a fake directory
|
||||
/// structure as pairs of directories and their immediate child entries.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MockPathCompletionContext {
|
||||
home_directory: Option<String>,
|
||||
pwd: TypedPathBuf,
|
||||
directory_to_entries: HashMap<PathBuf, Vec<EngineDirEntry>>,
|
||||
}
|
||||
|
||||
impl MockPathCompletionContext {
|
||||
pub fn new(pwd: TypedPathBuf) -> Self {
|
||||
Self {
|
||||
home_directory: TEST_SESSION_HOME_DIR.clone(),
|
||||
pwd,
|
||||
directory_to_entries: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_home_directory(mut self, home_directory: String) -> Self {
|
||||
self.home_directory = Some(home_directory);
|
||||
self
|
||||
}
|
||||
|
||||
/// The given entries are mocked as children of the context's `pwd`, such that `entries`
|
||||
/// is returned if the completions engine calls
|
||||
/// `path_ctx.list_directory_entries(path_ctx.pwd())`.
|
||||
pub fn with_entries_in_pwd(
|
||||
mut self,
|
||||
entries: impl IntoIterator<Item = EngineDirEntry>,
|
||||
) -> Self {
|
||||
let Ok(pwd) = PathBuf::try_from(self.pwd.clone()) else {
|
||||
log::warn!(
|
||||
"Failed to convert TypedPath to OS-native path. Not populating entries for pwd"
|
||||
);
|
||||
return self;
|
||||
};
|
||||
|
||||
self.directory_to_entries
|
||||
.insert(pwd, entries.into_iter().collect());
|
||||
self
|
||||
}
|
||||
|
||||
/// The given entries are mocked as children of the given `directory_path`, such that `entries`
|
||||
/// is returned if the completions engine calls
|
||||
/// `path_ctx.list_directory_entries(directory_path.as_path())`.
|
||||
pub fn with_entries(
|
||||
mut self,
|
||||
directory_path: TypedPathBuf,
|
||||
entries: impl IntoIterator<Item = EngineDirEntry>,
|
||||
) -> Self {
|
||||
let Ok(directory_path) = PathBuf::try_from(directory_path) else {
|
||||
log::warn!(
|
||||
"Failed to convert TypedPath to OS-native path. Not populating entries for directory"
|
||||
);
|
||||
return self;
|
||||
};
|
||||
|
||||
self.directory_to_entries
|
||||
.insert(directory_path.to_path_buf(), entries.into_iter().collect());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for MockPathCompletionContext {
|
||||
fn default() -> Self {
|
||||
#[cfg(unix)]
|
||||
let pwd = "/home/";
|
||||
#[cfg(windows)]
|
||||
let pwd = r"C:\Users\";
|
||||
Self::new(TypedPathBuf::from(pwd))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PathCompletionContext for MockPathCompletionContext {
|
||||
async fn list_directory_entries(&self, directory: TypedPathBuf) -> Arc<Vec<EngineDirEntry>> {
|
||||
let Ok(directory) = PathBuf::try_from(directory) else {
|
||||
log::warn!(
|
||||
"Failed to convert TypedPath to OS-native path, returning empty directory entries"
|
||||
);
|
||||
return Arc::new(Vec::new());
|
||||
};
|
||||
self.directory_to_entries
|
||||
.get(&directory)
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
.into()
|
||||
}
|
||||
|
||||
fn shell_family(&self) -> ShellFamily {
|
||||
ShellFamily::Posix
|
||||
}
|
||||
|
||||
fn home_directory(&self) -> Option<&str> {
|
||||
self.home_directory.as_deref()
|
||||
}
|
||||
|
||||
fn pwd(&self) -> TypedPath<'_> {
|
||||
self.pwd.to_path()
|
||||
}
|
||||
|
||||
fn path_separators(&self) -> PathSeparators {
|
||||
PathSeparators::for_unix()
|
||||
}
|
||||
}
|
||||
|
||||
/// A fake `CompletionContext` implementation for use in testing.
|
||||
pub struct FakeCompletionContext {
|
||||
top_level_commands: Vec<SmolStr>,
|
||||
aliases: Option<HashMap<SmolStr, String>>,
|
||||
abbreviations: Option<HashMap<SmolStr, String>>,
|
||||
functions: Option<HashSet<SmolStr>>,
|
||||
builtins: Option<HashSet<SmolStr>>,
|
||||
supports_autocd: Option<bool>,
|
||||
environment_variable_names: Option<HashSet<SmolStr>>,
|
||||
path_completion_context: Option<MockPathCompletionContext>,
|
||||
generator_context: Option<MockGeneratorContext>,
|
||||
command_case_sensitivity: TopLevelCommandCaseSensitivity,
|
||||
escape_char: EscapeChar,
|
||||
shell_family: Option<ShellFamily>,
|
||||
|
||||
command_registry: CommandRegistry,
|
||||
|
||||
#[cfg(feature = "v2")]
|
||||
js_ctx: v2::FakeJsExecutionContext,
|
||||
}
|
||||
|
||||
impl FakeCompletionContext {
|
||||
pub fn new(command_registry: CommandRegistry) -> Self {
|
||||
Self {
|
||||
command_registry,
|
||||
supports_autocd: None,
|
||||
environment_variable_names: None,
|
||||
aliases: None,
|
||||
abbreviations: None,
|
||||
functions: None,
|
||||
builtins: None,
|
||||
top_level_commands: Vec::default(),
|
||||
path_completion_context: None,
|
||||
generator_context: None,
|
||||
command_case_sensitivity: TopLevelCommandCaseSensitivity::CaseInsensitive,
|
||||
escape_char: EscapeChar::Backslash,
|
||||
shell_family: None,
|
||||
|
||||
#[cfg(feature = "v2")]
|
||||
js_ctx: v2::FakeJsExecutionContext {},
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the "top-level" commands available for root command completion. Note that if the
|
||||
/// context includes aliases and/or abbreviations, the aliases and abbreviations must also be
|
||||
/// included in this list. The given list defines all strings that are eligible to be suggested
|
||||
/// in the root command position.
|
||||
pub fn with_top_level_commands(
|
||||
mut self,
|
||||
top_level_commands: impl IntoIterator<Item = impl Into<SmolStr>>,
|
||||
) -> Self {
|
||||
self.top_level_commands = top_level_commands.into_iter().map(Into::into).collect();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_case_sensitivity(mut self) -> Self {
|
||||
self.command_case_sensitivity = TopLevelCommandCaseSensitivity::CaseSensitive;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_supports_autocd(mut self, supports_autocd: bool) -> Self {
|
||||
self.supports_autocd = Some(supports_autocd);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_environment_variable_names(
|
||||
mut self,
|
||||
environment_variable_names: HashSet<SmolStr>,
|
||||
) -> Self {
|
||||
self.environment_variable_names = Some(environment_variable_names);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_aliases(mut self, aliases: HashMap<SmolStr, String>) -> Self {
|
||||
self.aliases = Some(aliases);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_abbreviations(mut self, abbreviations: HashMap<SmolStr, String>) -> Self {
|
||||
self.abbreviations = Some(abbreviations);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_functions(mut self, functions: HashSet<SmolStr>) -> Self {
|
||||
self.functions = Some(functions);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_builtins(mut self, builtins: HashSet<SmolStr>) -> Self {
|
||||
self.builtins = Some(builtins);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_path_completion_context(mut self, path_ctx: MockPathCompletionContext) -> Self {
|
||||
self.path_completion_context = Some(path_ctx);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_generator_context(mut self, generator_ctx: MockGeneratorContext) -> Self {
|
||||
self.generator_context = Some(generator_ctx);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_shell_family(mut self, shell_family: ShellFamily) -> Self {
|
||||
self.shell_family = Some(shell_family);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl CompletionContext for FakeCompletionContext {
|
||||
fn path_completion_context(&self) -> Option<&dyn PathCompletionContext> {
|
||||
self.path_completion_context
|
||||
.as_ref()
|
||||
.map(|context| context as &dyn PathCompletionContext)
|
||||
}
|
||||
|
||||
fn generator_context(&self) -> Option<&dyn GeneratorContext> {
|
||||
self.generator_context
|
||||
.as_ref()
|
||||
.map(|context| context as &dyn GeneratorContext)
|
||||
}
|
||||
|
||||
#[cfg(feature = "v2")]
|
||||
fn js_context(&self) -> Option<&dyn crate::completer::context::JsExecutionContext> {
|
||||
Some(&self.js_ctx)
|
||||
}
|
||||
|
||||
fn top_level_commands(&self) -> Box<dyn Iterator<Item = &str> + '_> {
|
||||
Box::new(
|
||||
self.top_level_commands
|
||||
.iter()
|
||||
.map(|command| command.as_str()),
|
||||
)
|
||||
}
|
||||
|
||||
fn command_case_sensitivity(&self) -> TopLevelCommandCaseSensitivity {
|
||||
self.command_case_sensitivity
|
||||
}
|
||||
|
||||
fn escape_char(&self) -> EscapeChar {
|
||||
self.escape_char
|
||||
}
|
||||
|
||||
fn aliases(&self) -> Box<dyn Iterator<Item = (&str, &str)> + '_> {
|
||||
Box::new(
|
||||
self.aliases
|
||||
.as_ref()
|
||||
.into_iter()
|
||||
.flat_map(|aliases| aliases.iter())
|
||||
.map(|(alias, command)| (alias.as_str(), command.as_str())),
|
||||
)
|
||||
}
|
||||
|
||||
fn alias_command(&self, alias: &str) -> Option<&str> {
|
||||
self.aliases
|
||||
.as_ref()
|
||||
.and_then(|aliases| aliases.get(alias))
|
||||
.map(Deref::deref)
|
||||
}
|
||||
|
||||
fn abbreviations(&self) -> Option<&HashMap<SmolStr, String>> {
|
||||
self.abbreviations.as_ref()
|
||||
}
|
||||
|
||||
fn functions(&self) -> Option<&HashSet<SmolStr>> {
|
||||
self.functions.as_ref()
|
||||
}
|
||||
|
||||
fn builtins(&self) -> Option<&HashSet<SmolStr>> {
|
||||
self.builtins.as_ref()
|
||||
}
|
||||
|
||||
fn environment_variable_names(&self) -> Option<&HashSet<SmolStr>> {
|
||||
self.environment_variable_names.as_ref()
|
||||
}
|
||||
|
||||
fn shell_supports_autocd(&self) -> Option<bool> {
|
||||
self.supports_autocd
|
||||
}
|
||||
|
||||
fn command_registry(&self) -> &CommandRegistry {
|
||||
&self.command_registry
|
||||
}
|
||||
|
||||
fn shell_family(&self) -> Option<ShellFamily> {
|
||||
self.shell_family
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
use async_trait::async_trait;
|
||||
use warp_js::{JsFunctionId, SerializedJsValue};
|
||||
|
||||
use crate::{
|
||||
completer::context::{JsExecutionContext, JsExecutionError},
|
||||
signatures::{
|
||||
testing::{TEST_GENERATOR_1_JS_FUNCTION, TEST_GENERATOR_2_JS_FUNCTION},
|
||||
GeneratorResults, Suggestion,
|
||||
},
|
||||
};
|
||||
|
||||
pub struct FakeJsExecutionContext {}
|
||||
|
||||
#[async_trait]
|
||||
impl JsExecutionContext for FakeJsExecutionContext {
|
||||
async fn call_js_function(
|
||||
&self,
|
||||
_input: SerializedJsValue,
|
||||
function_id: JsFunctionId,
|
||||
) -> Result<SerializedJsValue, JsExecutionError> {
|
||||
let results = match function_id {
|
||||
id if TEST_GENERATOR_1_JS_FUNCTION.id == id => GeneratorResults {
|
||||
suggestions: vec![
|
||||
Suggestion {
|
||||
value: "foo".to_owned(),
|
||||
..Default::default()
|
||||
},
|
||||
Suggestion {
|
||||
value: "bar".to_owned(),
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
is_ordered: false,
|
||||
},
|
||||
id if TEST_GENERATOR_2_JS_FUNCTION.id == id => GeneratorResults {
|
||||
suggestions: vec![
|
||||
Suggestion {
|
||||
value: "def".to_owned(),
|
||||
..Default::default()
|
||||
},
|
||||
Suggestion {
|
||||
value: "abc".to_owned(),
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
is_ordered: true,
|
||||
},
|
||||
_ => panic!("Unexpected JS function call!"),
|
||||
};
|
||||
SerializedJsValue::from_value(results).map_err(JsExecutionError::Serialization)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use itertools::Itertools;
|
||||
|
||||
use crate::completer::{Priority, Suggestion, SuggestionType};
|
||||
|
||||
/// Ordering tests for Suggestions
|
||||
#[test]
|
||||
fn test_suggestions_cmp_display() {
|
||||
let display_names = [
|
||||
"dir2/".to_owned(),
|
||||
"abc".to_owned(),
|
||||
"file".to_owned(),
|
||||
"abc2".to_owned(),
|
||||
"dir1".to_owned(),
|
||||
];
|
||||
|
||||
let suggestions: Vec<Suggestion> = display_names
|
||||
.iter()
|
||||
.map(|display| Suggestion {
|
||||
display: Arc::new(display.to_owned()),
|
||||
replacement: "dummy".to_owned(),
|
||||
description: None,
|
||||
suggestion_type: SuggestionType::Argument,
|
||||
priority: Priority::default(),
|
||||
override_icon: None,
|
||||
is_hidden: false,
|
||||
file_type: None,
|
||||
is_abbreviation: false,
|
||||
})
|
||||
.sorted_by(Suggestion::cmp_by_display)
|
||||
.collect();
|
||||
|
||||
assert_eq!(suggestions[0].display.as_str(), display_names[1]);
|
||||
assert_eq!(suggestions[1].display.as_str(), display_names[3]);
|
||||
assert_eq!(suggestions[2].display.as_str(), display_names[4]);
|
||||
assert_eq!(suggestions[3].display.as_str(), display_names[0]);
|
||||
assert_eq!(suggestions[4].display.as_str(), display_names[2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_suggestions_cmp_by_reversed_priority_and_display() {
|
||||
let important_globally = Priority::max();
|
||||
let default = Priority::default();
|
||||
let not_important_globally = Priority::new(-40);
|
||||
let priorities = [default, important_globally, not_important_globally, default];
|
||||
|
||||
let suggestions: Vec<Suggestion> = priorities
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(idx, priority)| Suggestion {
|
||||
display: Arc::new(format!("status_{}", priorities.len() - idx)),
|
||||
replacement: "status".to_owned(),
|
||||
description: None,
|
||||
suggestion_type: SuggestionType::Argument,
|
||||
priority,
|
||||
override_icon: None,
|
||||
is_hidden: false,
|
||||
file_type: None,
|
||||
is_abbreviation: false,
|
||||
})
|
||||
.sorted_by(Suggestion::cmp_by_reversed_priority_and_display)
|
||||
.collect();
|
||||
|
||||
assert_eq!(suggestions[0].priority, important_globally);
|
||||
assert_eq!(suggestions[1].priority, default);
|
||||
assert_eq!(suggestions[2].priority, default);
|
||||
assert_eq!(suggestions[3].priority, not_important_globally);
|
||||
|
||||
// For the suggestions that tie in priority, we should lexicographically compare their display
|
||||
// names, with lower lexicographic order being higher priority.
|
||||
assert!(suggestions[1].display < suggestions[2].display);
|
||||
}
|
||||
Reference in New Issue
Block a user