Rebrand to Galaxy, major improvements to Bedrock support, still needs some TLC though
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
[package]
|
||||
name = "galaxy_completer"
|
||||
authors = ["Warp Team <dev@warp.dev>"]
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Warp completions engine"
|
||||
publish.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
async-recursion.workspace = true
|
||||
async-trait.workspace = true
|
||||
anyhow.workspace = true
|
||||
bincode.workspace = true
|
||||
cfg-if.workspace = true
|
||||
clap.workspace = true
|
||||
derive-new = "0.5.8"
|
||||
dirs.workspace = true
|
||||
futures.workspace = true
|
||||
fuzzy_match.workspace = true
|
||||
getset.workspace = true
|
||||
instant.workspace = true
|
||||
itertools.workspace = true
|
||||
lazy_static.workspace = true
|
||||
log.workspace = true
|
||||
memo-map.workspace = true
|
||||
regex.workspace = true
|
||||
serde.workspace = true
|
||||
shellexpand.workspace = true
|
||||
smol_str.workspace = true
|
||||
string-offset.workspace = true
|
||||
thiserror.workspace = true
|
||||
galaxy_cli.workspace = true
|
||||
galaxy_core.workspace = true
|
||||
galaxy_js = { workspace = true, optional = true }
|
||||
galaxyui.workspace = true
|
||||
galaxy_util.workspace = true
|
||||
typed-path.workspace = true
|
||||
|
||||
[target.'cfg(target_family = "wasm")'.dependencies]
|
||||
# For wasm, we don't use the feature "embed-signatures".
|
||||
warp-command-signatures = {workspace = true}
|
||||
|
||||
[target.'cfg(not(target_family = "wasm"))'.dependencies]
|
||||
command = { path = "../command" }
|
||||
rquickjs = { workspace = true, optional = true }
|
||||
warp-command-signatures = {workspace = true, features = ["embed-signatures"]}
|
||||
|
||||
[target.'cfg(not(target_family = "wasm"))'.dev-dependencies]
|
||||
command = { path = "../command", features = ["test-util"] }
|
||||
|
||||
[dev-dependencies]
|
||||
anyhow.workspace = true
|
||||
# Enable the `test-util` feature for our own package when running tests.
|
||||
galaxy_completer = { path = ".", features = ["test-util"] }
|
||||
|
||||
[features]
|
||||
# Enables the completions-on-js implementation of the completion engine.
|
||||
# Note that this feature is only compatible with non-wasm targets.
|
||||
v2 = ["dep:galaxy_js", "dep:rquickjs"]
|
||||
# Enables utilities for testing.
|
||||
test-util = ["galaxy_js?/test-util"]
|
||||
@@ -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 galaxy_core::command::ExitCode;
|
||||
use galaxy_util::path::{EscapeChar, ShellFamily};
|
||||
use galaxyui::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 galaxy_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 galaxyui::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> {
|
||||
galaxyui::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 galaxy_core::features::FeatureFlag;
|
||||
use galaxy_util::path::ShellFamily;
|
||||
|
||||
use crate::completer::{
|
||||
context::CompletionContext,
|
||||
engine::{
|
||||
self,
|
||||
path::{sorted_directories_relative_to, sorted_paths_relative_to, EngineFileType},
|
||||
},
|
||||
matchers::MatchStrategy,
|
||||
suggest::{
|
||||
CompleterOptions, CompletionsFallbackStrategy, MatchedSuggestion, Suggestion,
|
||||
SuggestionType,
|
||||
},
|
||||
CommandExitStatus, GeneratorContext, LocationType,
|
||||
};
|
||||
|
||||
use crate::meta::{Span, Spanned};
|
||||
use crate::parsers::{
|
||||
ClassifiedCommand, ParseError, ParseErrorReason, ParsedToken, SignatureAtTokenIndex,
|
||||
};
|
||||
|
||||
use crate::parsers::{
|
||||
hir::{Command, ShellCommand},
|
||||
ArgumentError::{MissingMandatoryPositional, MissingValueForName, UnexpectedArgument},
|
||||
};
|
||||
|
||||
use super::add_extra_positional;
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn complete(
|
||||
line: &str,
|
||||
tokens_from_command: &[&str],
|
||||
classified_command: ClassifiedCommand,
|
||||
found_signature: Option<SignatureAtTokenIndex<'_>>,
|
||||
location: &Spanned<LocationType>,
|
||||
parsed_argument: &ParsedToken,
|
||||
session_env_vars: Option<&HashMap<String, String>>,
|
||||
options: &CompleterOptions,
|
||||
ctx: &dyn CompletionContext,
|
||||
) -> Vec<MatchedSuggestion> {
|
||||
let mut suggestions = Default::default();
|
||||
|
||||
// True if and only if we called the complete function.
|
||||
let mut arg_has_spec = false;
|
||||
if let Some(found_signature) = found_signature {
|
||||
if let Command::Classified(mut shell_command) = classified_command.command {
|
||||
suggestions = match classified_command.error {
|
||||
Some(error) => {
|
||||
let (results, complete_called) = suggestions_for_parse_error(
|
||||
error,
|
||||
&mut shell_command,
|
||||
tokens_from_command,
|
||||
&classified_command.env_vars,
|
||||
session_env_vars,
|
||||
&location.span,
|
||||
found_signature.signature,
|
||||
found_signature.dynamic_completion_data,
|
||||
line,
|
||||
options,
|
||||
ctx,
|
||||
)
|
||||
.await;
|
||||
arg_has_spec = complete_called;
|
||||
results
|
||||
}
|
||||
None => {
|
||||
let (results, complete_called) = suggestions_for_last_argument(
|
||||
&mut shell_command,
|
||||
tokens_from_command,
|
||||
line.ends_with(char::is_whitespace),
|
||||
&classified_command.env_vars,
|
||||
session_env_vars,
|
||||
&location.span,
|
||||
found_signature.signature,
|
||||
found_signature.dynamic_completion_data,
|
||||
options,
|
||||
ctx,
|
||||
)
|
||||
.await;
|
||||
arg_has_spec = complete_called;
|
||||
results
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we were unsuccessful in getting completions *and* there was not
|
||||
// a completion spec we attempted, fallback to the fallback type (if any).
|
||||
if suggestions.is_empty()
|
||||
&& !arg_has_spec
|
||||
&& matches!(
|
||||
options.fallback_strategy,
|
||||
CompletionsFallbackStrategy::FilePaths
|
||||
)
|
||||
{
|
||||
if let Some(path_completion_context) = ctx.path_completion_context() {
|
||||
suggestions = sorted_paths_relative_to(
|
||||
parsed_argument,
|
||||
options.match_strategy,
|
||||
path_completion_context,
|
||||
)
|
||||
.await
|
||||
.into_iter()
|
||||
.collect();
|
||||
}
|
||||
}
|
||||
|
||||
suggestions
|
||||
}
|
||||
|
||||
/// Generate suggestions for the case where there was a parse error. Given there
|
||||
/// was a parse error, there could be a few possibilities for options we'd want
|
||||
/// to suggest based on the error from the parser:
|
||||
/// 1) MissingMandatoryPositional: The command is missing a mandatory argument
|
||||
/// at a certain index. In this case, we read the index from the error and
|
||||
/// execute `complete` on the argument to get the possible suggestions for the
|
||||
/// argument.
|
||||
/// 2) MissingValueForName: A flag was supplied without a corresponding value, in
|
||||
/// this case we suggest the possible arguments for the option that is missing.
|
||||
/// 3) Unexpected argument: An extra argument was supplied. This extra argument
|
||||
/// could be the prefix of a subcommand, so we suggest subcommands that start
|
||||
/// with the value of the extra argument.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn suggestions_for_parse_error(
|
||||
root_err: ParseError,
|
||||
shell_command: &mut ShellCommand,
|
||||
tokens_from_command: &[&str],
|
||||
command_env_vars: &[String],
|
||||
session_env_vars: Option<&HashMap<String, String>>,
|
||||
cursor: &Span,
|
||||
signature: &Signature,
|
||||
dynamic_completion_data: Option<&DynamicCompletionData>,
|
||||
line: &str,
|
||||
options: &CompleterOptions,
|
||||
ctx: &dyn CompletionContext,
|
||||
) -> (Vec<MatchedSuggestion>, bool) {
|
||||
match root_err.reason {
|
||||
ParseErrorReason::ArgumentError {
|
||||
command: _,
|
||||
error:
|
||||
MissingValueForName {
|
||||
name,
|
||||
missing_arg_index,
|
||||
},
|
||||
} => {
|
||||
// If there was trailing whitespace in the line, respect the error and try to to complete based
|
||||
// on the missing argument. If there wasn't any trailing whitespace, the user is trying
|
||||
// to complete an argument before the one that's missing (such as `git push ori<tab>`) so we
|
||||
// treat this as successful parse so that we can parse out the argument correctly.
|
||||
if shell_command.args.ending_whitespace.is_some() {
|
||||
let mut arg_has_spec = false;
|
||||
|
||||
let argument = signature
|
||||
.options()
|
||||
.iter()
|
||||
.find(|opt| opt.has_name(name.as_str()))
|
||||
.and_then(|opt| opt.arguments().get(missing_arg_index));
|
||||
|
||||
let results = match argument {
|
||||
None => Default::default(),
|
||||
Some(arg) => {
|
||||
// Complete on the exact missing argument for the flag
|
||||
// (rather than combining the completions for _all_ of the flags' args)
|
||||
arg_has_spec = true;
|
||||
generate_suggestions_for_argument(
|
||||
arg,
|
||||
&ParsedToken::empty(),
|
||||
tokens_from_command,
|
||||
command_env_vars,
|
||||
session_env_vars,
|
||||
line.ends_with(char::is_whitespace),
|
||||
dynamic_completion_data,
|
||||
options,
|
||||
ctx,
|
||||
)
|
||||
.await
|
||||
}
|
||||
};
|
||||
|
||||
return (results, arg_has_spec);
|
||||
} else {
|
||||
return suggestions_for_last_argument(
|
||||
shell_command,
|
||||
tokens_from_command,
|
||||
line.ends_with(char::is_whitespace),
|
||||
command_env_vars,
|
||||
session_env_vars,
|
||||
cursor,
|
||||
signature,
|
||||
dynamic_completion_data,
|
||||
options,
|
||||
ctx,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
ParseErrorReason::ArgumentError {
|
||||
command: _,
|
||||
error:
|
||||
MissingMandatoryPositional {
|
||||
name: _name,
|
||||
positional_index,
|
||||
},
|
||||
} => {
|
||||
// If there was ending whitespace in the line respect the error and try to to complete based
|
||||
// on the missing positional. If there was not an ending whitespace, the user is try trying
|
||||
// to complete a positional before the one that's missing such as `git push ori<tab>` so we
|
||||
// treat this as successful parse so that we can parse out the positional correctly.
|
||||
if shell_command.args.ending_whitespace.is_some() {
|
||||
add_extra_positional(shell_command, cursor);
|
||||
let argument = signature
|
||||
.arguments()
|
||||
.get(positional_index)
|
||||
.expect("argument should exist based on error from parser");
|
||||
let results = generate_suggestions_for_argument(
|
||||
argument,
|
||||
&ParsedToken::empty(),
|
||||
tokens_from_command,
|
||||
command_env_vars,
|
||||
session_env_vars,
|
||||
line.ends_with(char::is_whitespace),
|
||||
dynamic_completion_data,
|
||||
options,
|
||||
ctx,
|
||||
)
|
||||
.await;
|
||||
return (results, true);
|
||||
} else {
|
||||
return suggestions_for_last_argument(
|
||||
shell_command,
|
||||
tokens_from_command,
|
||||
line.ends_with(char::is_whitespace),
|
||||
command_env_vars,
|
||||
session_env_vars,
|
||||
cursor,
|
||||
signature,
|
||||
dynamic_completion_data,
|
||||
options,
|
||||
ctx,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
ParseErrorReason::ArgumentError {
|
||||
command: _,
|
||||
error: UnexpectedArgument(arg),
|
||||
} => {
|
||||
if arg.span.end() == line.len() {
|
||||
// The unexpected argument could be a prefix for a subcommand.
|
||||
let prefix = arg.item.as_str();
|
||||
let results = (signature.subcommands().iter().filter_map(|subcmd| {
|
||||
options
|
||||
.match_strategy
|
||||
.get_match_type(prefix, subcmd.name())
|
||||
.map(|match_type| {
|
||||
let suggestion = Suggestion::with_same_display_and_replacement(
|
||||
subcmd.name.clone(),
|
||||
subcmd.description.as_ref().cloned(),
|
||||
SuggestionType::Subcommand,
|
||||
subcmd.priority.into(),
|
||||
);
|
||||
MatchedSuggestion::new(suggestion, match_type)
|
||||
})
|
||||
}))
|
||||
.sorted_by(MatchedSuggestion::cmp_by_display)
|
||||
.collect();
|
||||
return (results, false);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
(Default::default(), false)
|
||||
}
|
||||
|
||||
/// Finds the last positional or named argument (an argument for a flag) in the line and generates
|
||||
/// suggestions based on the type of the argument.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn suggestions_for_last_argument(
|
||||
shell_command: &mut ShellCommand,
|
||||
tokens_from_command: &[&str],
|
||||
has_trailing_whitespace: bool,
|
||||
command_env_vars: &[String],
|
||||
session_env_vars: Option<&HashMap<String, String>>,
|
||||
cursor: &Span,
|
||||
signature: &Signature,
|
||||
dynamic_completion_data: Option<&DynamicCompletionData>,
|
||||
options: &CompleterOptions,
|
||||
ctx: &dyn CompletionContext,
|
||||
) -> (Vec<MatchedSuggestion>, bool) {
|
||||
if shell_command.args.ending_whitespace.is_some() {
|
||||
add_extra_positional(shell_command, cursor);
|
||||
}
|
||||
|
||||
// Find the last positional and named value within the the command that the user entered.
|
||||
// Whichever ends last is the value we're trying to complete on.
|
||||
let last_positional = shell_command.last_positional();
|
||||
let last_named_value = shell_command.last_named_argument();
|
||||
|
||||
let results = match (last_positional, last_named_value) {
|
||||
(Some(last_positional), Some(last_named_arg)) => {
|
||||
if last_positional.span.end() > last_named_arg.span.end() {
|
||||
// If there is a positional and a named argument (option), we want to
|
||||
// complete on the option's arguments only if the option is variadic.
|
||||
// Otherwise, the option's arguments are already satisfied and we
|
||||
// should complete the positional.
|
||||
// TODO(CORE-646): If the option is variadic, the user could be trying
|
||||
// to complete the option's arguments or the positional argument, so
|
||||
// we should show suggestions for both.
|
||||
//
|
||||
// When the flag's value was specified via '=' (e.g., --strategy=octopus),
|
||||
// the flag is fully satisfied by that single token regardless of
|
||||
// whether its argument is variadic, so skip the variadic check.
|
||||
let flag_name = last_named_arg.item.name;
|
||||
let is_eq_delimited = tokens_from_command
|
||||
.iter()
|
||||
.any(|t| t.split_once('=').is_some_and(|(n, _)| n == flag_name));
|
||||
let option_with_arguments = if is_eq_delimited {
|
||||
None
|
||||
} else {
|
||||
signature
|
||||
.options()
|
||||
.iter()
|
||||
.find(|opt| opt.has_name(flag_name))
|
||||
.filter(|opt| opt.arguments().iter().any(|arg| arg.is_variadic))
|
||||
};
|
||||
let arguments =
|
||||
option_with_arguments.map_or(signature.arguments(), |opt| opt.arguments());
|
||||
|
||||
complete_positional(
|
||||
&shell_command,
|
||||
tokens_from_command,
|
||||
has_trailing_whitespace,
|
||||
command_env_vars,
|
||||
session_env_vars,
|
||||
arguments,
|
||||
signature.subcommands(),
|
||||
dynamic_completion_data,
|
||||
last_positional.item,
|
||||
options,
|
||||
ctx,
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
complete_option(
|
||||
signature,
|
||||
dynamic_completion_data,
|
||||
tokens_from_command,
|
||||
has_trailing_whitespace,
|
||||
command_env_vars,
|
||||
session_env_vars,
|
||||
last_named_arg.item.name,
|
||||
last_named_arg.item.parsed_token,
|
||||
options,
|
||||
ctx,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
(Some(last_positional), None) => {
|
||||
complete_positional(
|
||||
&shell_command,
|
||||
tokens_from_command,
|
||||
has_trailing_whitespace,
|
||||
command_env_vars,
|
||||
session_env_vars,
|
||||
signature.arguments(),
|
||||
signature.subcommands(),
|
||||
dynamic_completion_data,
|
||||
last_positional.item,
|
||||
options,
|
||||
ctx,
|
||||
)
|
||||
.await
|
||||
}
|
||||
(None, Some(last_named_arg)) => {
|
||||
complete_option(
|
||||
signature,
|
||||
dynamic_completion_data,
|
||||
tokens_from_command,
|
||||
has_trailing_whitespace,
|
||||
command_env_vars,
|
||||
session_env_vars,
|
||||
last_named_arg.item.name,
|
||||
last_named_arg.item.parsed_token,
|
||||
options,
|
||||
ctx,
|
||||
)
|
||||
.await
|
||||
}
|
||||
(None, None) => Default::default(),
|
||||
};
|
||||
|
||||
(results, true)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn complete_option(
|
||||
signature: &Signature,
|
||||
dynamic_completion_data: Option<&DynamicCompletionData>,
|
||||
tokens_from_command: &[&str],
|
||||
has_trailing_whitespace: bool,
|
||||
command_env_vars: &[String],
|
||||
session_env_vars: Option<&HashMap<String, String>>,
|
||||
option_name: &str,
|
||||
parsed_token: &ParsedToken,
|
||||
options: &CompleterOptions,
|
||||
ctx: &dyn CompletionContext,
|
||||
) -> Vec<MatchedSuggestion> {
|
||||
let last_argument = signature
|
||||
.options()
|
||||
.iter()
|
||||
.find(|opt| opt.has_name(option_name))
|
||||
.and_then(|opt| opt.arguments().last());
|
||||
|
||||
match last_argument {
|
||||
None => Default::default(),
|
||||
Some(argument) => {
|
||||
generate_suggestions_for_argument(
|
||||
argument,
|
||||
parsed_token,
|
||||
tokens_from_command,
|
||||
command_env_vars,
|
||||
session_env_vars,
|
||||
has_trailing_whitespace,
|
||||
dynamic_completion_data,
|
||||
options,
|
||||
ctx,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn complete_positional(
|
||||
shell_command: &&mut ShellCommand,
|
||||
tokens_from_command: &[&str],
|
||||
has_trailing_whitespace: bool,
|
||||
command_env_vars: &[String],
|
||||
session_env_vars: Option<&HashMap<String, String>>,
|
||||
arguments: &[Argument],
|
||||
subcommands: &[Signature],
|
||||
dynamic_completion_data: Option<&DynamicCompletionData>,
|
||||
parsed_token: &ParsedToken,
|
||||
options: &CompleterOptions,
|
||||
ctx: &dyn CompletionContext,
|
||||
) -> Vec<MatchedSuggestion> {
|
||||
let mut suggestions = Default::default();
|
||||
// Whether we should suggest subcommands or continue to complete on positionals for the current
|
||||
// command.
|
||||
let mut suggest_subcommands = true;
|
||||
|
||||
if let Some(positionals) = &shell_command.args.positionals.as_ref() {
|
||||
if !arguments.is_empty() {
|
||||
let positional_index = positionals.len() - 1;
|
||||
|
||||
let arg = match arguments
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find(|(idx, arg)| idx <= &positional_index && arg.is_variadic())
|
||||
{
|
||||
None => arguments.get(positionals.len() - 1),
|
||||
Some((_, arg)) => {
|
||||
// If the argument is required, we shouldn't continue to suggest subcommands. If
|
||||
// the argument is optional, we will show completions for the current argument
|
||||
// and possible subcommands for the current command.
|
||||
suggest_subcommands = !arg.is_required();
|
||||
Some(arg)
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(arg) = arg {
|
||||
suggestions = generate_suggestions_for_argument(
|
||||
arg,
|
||||
parsed_token,
|
||||
tokens_from_command,
|
||||
command_env_vars,
|
||||
session_env_vars,
|
||||
has_trailing_whitespace,
|
||||
dynamic_completion_data,
|
||||
options,
|
||||
ctx,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Since all the required positionals have been satisfied, suggest subcommands.
|
||||
// Note that since we're extending, subcommands will come after the arguments here,
|
||||
// so no need to do a final sort at the end.
|
||||
if suggest_subcommands {
|
||||
suggestions.extend(
|
||||
subcommands
|
||||
.iter()
|
||||
.filter_map(|subcmd| {
|
||||
options
|
||||
.match_strategy
|
||||
.get_match_type(parsed_token.as_str(), subcmd.name())
|
||||
.map(|match_type| {
|
||||
let suggestion = Suggestion::with_same_display_and_replacement(
|
||||
subcmd.name.clone(),
|
||||
subcmd.description.as_ref().cloned(),
|
||||
SuggestionType::Subcommand,
|
||||
subcmd.priority.into(),
|
||||
);
|
||||
MatchedSuggestion::new(suggestion, match_type)
|
||||
})
|
||||
})
|
||||
.sorted_by(MatchedSuggestion::cmp_by_display),
|
||||
);
|
||||
}
|
||||
suggestions
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn generate_suggestions_for_argument(
|
||||
argument: &Argument,
|
||||
parsed_token: &ParsedToken,
|
||||
tokens_from_command: &[&str],
|
||||
command_env_vars: &[String],
|
||||
session_env_vars: Option<&HashMap<String, String>>,
|
||||
has_trailing_whitespace: bool,
|
||||
dynamic_completion_data: Option<&DynamicCompletionData>,
|
||||
options: &CompleterOptions,
|
||||
ctx: &dyn CompletionContext,
|
||||
) -> Vec<MatchedSuggestion> {
|
||||
// If the argument is a top level command (such as `sudo {ARG}`), suggest top-level command
|
||||
// suggestions if the command doesn't have any existing argument types.
|
||||
if argument.is_command && argument.argument_types.is_empty() {
|
||||
return engine::command_suggestions(ctx, options.match_strategy, parsed_token).await;
|
||||
}
|
||||
|
||||
if argument.argument_types.is_empty()
|
||||
&& matches!(
|
||||
options.fallback_strategy,
|
||||
CompletionsFallbackStrategy::FilePaths
|
||||
)
|
||||
{
|
||||
// If the signature has an argument but no argument type to generate suggestions,
|
||||
// fallback to the fallback type if any.
|
||||
return match ctx.path_completion_context() {
|
||||
Some(path_completion_context) => sorted_paths_relative_to(
|
||||
parsed_token,
|
||||
options.match_strategy,
|
||||
path_completion_context,
|
||||
)
|
||||
.await
|
||||
.into_iter()
|
||||
.collect(),
|
||||
None => Default::default(),
|
||||
};
|
||||
}
|
||||
|
||||
// These are processed in the order that argument.argument_types is defined
|
||||
// (https://github.com/warpdotdev/command-signatures/blob/5e89fb22995cd5ca9f5609d75193018a2a194c59/completion-metadata/src/fig_types.rs#L288).
|
||||
// That's why we can just flat map here without thinking about order.
|
||||
// Even if there are multiple generators, they will appear one after the other and we will
|
||||
// simply concatenate their results (extracting the non-default priorities).
|
||||
let run_in_parallel = ctx
|
||||
.generator_context()
|
||||
.is_some_and(GeneratorContext::supports_parallel_execution);
|
||||
|
||||
if run_in_parallel {
|
||||
let par_iterator = argument.argument_types.iter().map(|argument_type| {
|
||||
generate_suggestions_for_argument_type(
|
||||
argument,
|
||||
argument_type,
|
||||
parsed_token,
|
||||
tokens_from_command,
|
||||
has_trailing_whitespace,
|
||||
command_env_vars,
|
||||
session_env_vars,
|
||||
options.match_strategy,
|
||||
dynamic_completion_data,
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
futures::future::join_all(par_iterator)
|
||||
.await
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect()
|
||||
} else {
|
||||
let mut completion_results = vec![];
|
||||
for argument_type in &argument.argument_types {
|
||||
let matched_suggestions = generate_suggestions_for_argument_type(
|
||||
argument,
|
||||
argument_type,
|
||||
parsed_token,
|
||||
tokens_from_command,
|
||||
has_trailing_whitespace,
|
||||
command_env_vars,
|
||||
session_env_vars,
|
||||
options.match_strategy,
|
||||
dynamic_completion_data,
|
||||
ctx,
|
||||
)
|
||||
.await;
|
||||
completion_results.extend(matched_suggestions);
|
||||
}
|
||||
|
||||
completion_results
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn generate_suggestions_for_argument_type(
|
||||
argument: &Argument,
|
||||
argument_type: &ArgumentType,
|
||||
parsed_token: &ParsedToken,
|
||||
tokens_from_command: &[&str],
|
||||
has_trailing_whitespace: bool,
|
||||
command_env_vars: &[String],
|
||||
session_env_vars: Option<&HashMap<String, String>>,
|
||||
matcher: MatchStrategy,
|
||||
dynamic_completion_data: Option<&DynamicCompletionData>,
|
||||
ctx: &dyn CompletionContext,
|
||||
) -> impl IntoIterator<Item = MatchedSuggestion> {
|
||||
match argument_type {
|
||||
ArgumentType::Suggestion(suggestion) => {
|
||||
let warp_suggestion: Suggestion = suggestion.clone().into();
|
||||
match matcher.get_match_type(parsed_token.as_str(), warp_suggestion.display.as_str()) {
|
||||
Some(match_type) => vec![MatchedSuggestion::new(warp_suggestion, match_type)],
|
||||
None => vec![],
|
||||
}
|
||||
}
|
||||
// Even if the argument is just files, recommend both files and folders since the
|
||||
// user may want to choose a nested file.
|
||||
ArgumentType::Template(Template {
|
||||
type_name: TemplateType::FilesAndFolders,
|
||||
filter_name,
|
||||
})
|
||||
| ArgumentType::Template(Template {
|
||||
type_name: TemplateType::Files { .. },
|
||||
filter_name,
|
||||
}) => {
|
||||
let path_suggestions = match ctx.path_completion_context() {
|
||||
Some(path_completion_context) => {
|
||||
sorted_paths_relative_to(parsed_token, matcher, path_completion_context).await
|
||||
}
|
||||
None => Vec::new(),
|
||||
};
|
||||
|
||||
match filter_name.as_ref().and_then(|filter_name| {
|
||||
argument.filter_template_by_name(
|
||||
dynamic_completion_data.map(DynamicCompletionData::filters),
|
||||
filter_name,
|
||||
)
|
||||
}) {
|
||||
Some(filter) => filter_path_suggestions(filter, path_suggestions.into_iter()),
|
||||
None => path_suggestions.into_iter().collect(),
|
||||
}
|
||||
}
|
||||
ArgumentType::Template(Template {
|
||||
type_name: TemplateType::Folders { .. },
|
||||
filter_name,
|
||||
}) => {
|
||||
let path_suggestions = match ctx.path_completion_context() {
|
||||
Some(path_completion_context) => {
|
||||
sorted_directories_relative_to(parsed_token, matcher, path_completion_context)
|
||||
.await
|
||||
}
|
||||
None => Vec::new(),
|
||||
};
|
||||
|
||||
match filter_name.as_ref().and_then(|filter_name| {
|
||||
argument.filter_template_by_name(
|
||||
dynamic_completion_data.map(DynamicCompletionData::filters),
|
||||
filter_name,
|
||||
)
|
||||
}) {
|
||||
Some(filter) => filter_path_suggestions(filter, path_suggestions.into_iter()),
|
||||
None => path_suggestions.into_iter().collect(),
|
||||
}
|
||||
}
|
||||
ArgumentType::Generator(generator_name) => {
|
||||
let generator = match argument.generator_by_name(
|
||||
dynamic_completion_data.map(DynamicCompletionData::generators),
|
||||
generator_name,
|
||||
) {
|
||||
None => return vec![],
|
||||
Some(generator) => generator,
|
||||
};
|
||||
|
||||
let shell_command = shell_command(
|
||||
generator,
|
||||
tokens_from_command,
|
||||
has_trailing_whitespace,
|
||||
ctx.shell_family().unwrap_or(ShellFamily::Posix),
|
||||
command_env_vars,
|
||||
);
|
||||
|
||||
let Some(generator_context) = ctx.generator_context() else {
|
||||
return vec![];
|
||||
};
|
||||
|
||||
let Ok(output) = generator_context
|
||||
.execute_command_at_pwd(&shell_command, session_env_vars.cloned())
|
||||
.await
|
||||
else {
|
||||
return vec![];
|
||||
};
|
||||
|
||||
match output.status {
|
||||
CommandExitStatus::Success => {
|
||||
let Ok(output_string) = output.to_string() else {
|
||||
return vec![];
|
||||
};
|
||||
|
||||
let results = generator.on_complete(&output_string);
|
||||
|
||||
let internal_suggestions = results
|
||||
.suggestions
|
||||
.into_iter()
|
||||
.map(Into::into)
|
||||
.filter_map(|suggestion: Suggestion| {
|
||||
let match_type = matcher
|
||||
.get_match_type(parsed_token.as_str(), suggestion.display.as_str());
|
||||
match_type
|
||||
.map(|match_type| MatchedSuggestion::new(suggestion, match_type))
|
||||
});
|
||||
|
||||
if results.is_ordered {
|
||||
internal_suggestions.collect()
|
||||
} else {
|
||||
internal_suggestions
|
||||
.sorted_by(MatchedSuggestion::cmp_by_display)
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
CommandExitStatus::Failure => {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
fn shell_command<'a>(
|
||||
generator: &'a Generator,
|
||||
tokens: &[&str],
|
||||
has_trailing_whitespace: bool,
|
||||
shell_family: ShellFamily,
|
||||
command_env_vars: &[String],
|
||||
) -> Cow<'a, str> {
|
||||
let shell = if cfg!(windows) && FeatureFlag::RunGeneratorsWithCmdExe.is_enabled() {
|
||||
warp_command_signatures::Shell::CmdExe
|
||||
} else {
|
||||
match shell_family {
|
||||
ShellFamily::Posix => warp_command_signatures::Shell::Posix,
|
||||
ShellFamily::PowerShell => warp_command_signatures::Shell::Powershell,
|
||||
}
|
||||
};
|
||||
|
||||
match &generator.process {
|
||||
GeneratorProcess::ShellCommand(command) => command.build(shell),
|
||||
GeneratorProcess::CommandFromTokens(command_generator) => {
|
||||
command_generator(tokens, has_trailing_whitespace, command_env_vars)
|
||||
.build(shell)
|
||||
.to_string()
|
||||
.into()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn filter_path_suggestions<'a>(
|
||||
filter: &'a TemplateFilter,
|
||||
path_suggestions: impl Iterator<Item = MatchedSuggestion> + 'a,
|
||||
) -> Vec<MatchedSuggestion> {
|
||||
path_suggestions
|
||||
.filter_map(|path_suggestion| {
|
||||
// Note that we need to read out these fields from the Suggestion struct before converting into command signature Suggestion struct
|
||||
// because they only belong to the app. We should write these fields back once we finished filtering
|
||||
let suggestion_type = path_suggestion.suggestion_type();
|
||||
|
||||
let file_type = path_suggestion
|
||||
.suggestion
|
||||
.file_type
|
||||
.unwrap_or(EngineFileType::File);
|
||||
filter
|
||||
.filter(path_suggestion.suggestion.into(), file_type.into())
|
||||
.map(|filter_suggestion| {
|
||||
let mut suggestion: Suggestion = filter_suggestion.into();
|
||||
suggestion.suggestion_type = suggestion_type;
|
||||
MatchedSuggestion::new(suggestion, path_suggestion.match_type)
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl From<warp_command_signatures::Suggestion> for Suggestion {
|
||||
/// Convert the `warp_command_signatures::Suggestion`s (which are meant to map
|
||||
/// 1:1 to the `completer::Suggestion`s)
|
||||
fn from(suggestion: warp_command_signatures::Suggestion) -> Self {
|
||||
let exact_string: SmolStr = suggestion.exact_string.into();
|
||||
let display = suggestion
|
||||
.display_name
|
||||
.map(Into::into)
|
||||
.unwrap_or_else(|| exact_string.clone());
|
||||
Self {
|
||||
display,
|
||||
replacement: exact_string,
|
||||
description: suggestion.description,
|
||||
suggestion_type: SuggestionType::Argument,
|
||||
priority: suggestion.priority.into(),
|
||||
override_icon: suggestion.icon,
|
||||
is_hidden: suggestion.is_hidden,
|
||||
file_type: None,
|
||||
is_abbreviation: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Suggestion> for warp_command_signatures::Suggestion {
|
||||
fn from(suggestion: Suggestion) -> warp_command_signatures::Suggestion {
|
||||
warp_command_signatures::Suggestion {
|
||||
exact_string: suggestion.display.as_ref().into(),
|
||||
description: suggestion.description,
|
||||
priority: suggestion.priority.into(),
|
||||
icon: suggestion.override_icon,
|
||||
is_hidden: suggestion.is_hidden,
|
||||
display_name: Some(suggestion.display.as_ref().into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(feature = "v2")] {
|
||||
mod v2;
|
||||
pub use v2::*;
|
||||
} else {
|
||||
mod legacy;
|
||||
pub use legacy::*;
|
||||
}
|
||||
}
|
||||
|
||||
use crate::{
|
||||
meta::{Span, SpannedItem},
|
||||
parsers::{
|
||||
hir::{Expression, ShellCommand},
|
||||
ParsedExpression, ParsedToken,
|
||||
},
|
||||
};
|
||||
|
||||
/// Creates a new empty positional arg in a shell_command. This is useful before evaluating args
|
||||
/// so that we don't include the extra whitespace at the end of the command (e.g. "cd ") within the
|
||||
/// top level command.
|
||||
fn add_extra_positional(shell_command: &mut ShellCommand, cursor: &Span) {
|
||||
let mut positional =
|
||||
vec![ParsedExpression::new(Expression::Literal, ParsedToken::empty()).spanned(*cursor)];
|
||||
|
||||
match shell_command.args.positionals.take() {
|
||||
Some(mut positionals) => {
|
||||
positionals.append(&mut positional);
|
||||
shell_command.args.positionals = Some(positionals);
|
||||
}
|
||||
None => shell_command.args.positionals = Some(positional),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,753 @@
|
||||
//! Contains the v2 implementation of argument suggestion generation that depends on the JS-compatible
|
||||
//! command signature struct (`crate::signatures::CommandSignature`).
|
||||
//!
|
||||
//! Functionality required for parity with the legacy implementation that is yet-to-be implemented
|
||||
//! is called out throughout with a TODO(completions-v2) comment.
|
||||
//!
|
||||
//! Functions in this file closely mirror their legacy implementations in `super::legacy`, though
|
||||
//! as more functionality is implemented in V2, eventually surpassing the functionality provided by
|
||||
//! the legacy implementation, the internal structure and logic in this module may begin to
|
||||
//! (appropriately) diverge.
|
||||
use std::borrow::Cow;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use itertools::Itertools;
|
||||
use smol_str::SmolStr;
|
||||
|
||||
use super::add_extra_positional;
|
||||
use crate::completer::GeneratorContext;
|
||||
use crate::{
|
||||
completer::{
|
||||
context::call_js_function,
|
||||
engine::path::{sorted_directories_relative_to, sorted_paths_relative_to},
|
||||
CommandExitStatus, CompleterOptions, CompletionContext, CompletionsFallbackStrategy,
|
||||
LocationType, MatchStrategy, MatchedSuggestion, Suggestion, SuggestionType,
|
||||
},
|
||||
meta::{Span, Spanned},
|
||||
parsers::{
|
||||
hir::{self, ShellCommand},
|
||||
ArgumentError::{MissingMandatoryPositional, MissingValueForName, UnexpectedArgument},
|
||||
ClassifiedCommand, ParseError, ParseErrorReason, ParsedToken,
|
||||
},
|
||||
signatures::{
|
||||
self, Argument, ArgumentValue, Command, GeneratorCompletionContext, GeneratorFn,
|
||||
GeneratorResults, GeneratorScript, TemplateType,
|
||||
},
|
||||
};
|
||||
|
||||
/// Returns completion suggestions for argument values based on the given `input`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn complete(
|
||||
input: &str,
|
||||
tokens_without_last_editing: &[&str],
|
||||
classified_command: ClassifiedCommand,
|
||||
found_signature: Option<&Command>,
|
||||
location: &Spanned<LocationType>,
|
||||
session_env_vars: Option<&HashMap<String, String>>,
|
||||
parsed_argument: &ParsedToken,
|
||||
options: &CompleterOptions,
|
||||
ctx: &dyn CompletionContext,
|
||||
) -> Vec<MatchedSuggestion> {
|
||||
let mut suggestions = Default::default();
|
||||
|
||||
// True if and only if we called the complete function.
|
||||
let mut arg_has_spec = false;
|
||||
if let Some(found_signature) = found_signature {
|
||||
if let hir::Command::Classified(mut shell_command) = classified_command.command {
|
||||
suggestions = match classified_command.error {
|
||||
Some(error) => {
|
||||
let (results, complete_called) = suggestions_for_parse_error(
|
||||
input,
|
||||
tokens_without_last_editing,
|
||||
&location.span,
|
||||
error,
|
||||
&mut shell_command,
|
||||
found_signature,
|
||||
session_env_vars,
|
||||
options,
|
||||
ctx,
|
||||
)
|
||||
.await;
|
||||
arg_has_spec = complete_called;
|
||||
results
|
||||
}
|
||||
None => {
|
||||
let (results, complete_called) = suggestions_for_last_argument(
|
||||
tokens_without_last_editing,
|
||||
&mut shell_command,
|
||||
&location.span,
|
||||
found_signature,
|
||||
session_env_vars,
|
||||
options,
|
||||
ctx,
|
||||
)
|
||||
.await;
|
||||
arg_has_spec = complete_called;
|
||||
results
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we were unsuccessful in getting completions *and* there was not
|
||||
// a completion spec we attempted, fallback to the fallback type (if any).
|
||||
if suggestions.is_empty()
|
||||
&& !arg_has_spec
|
||||
&& matches!(
|
||||
options.fallback_strategy,
|
||||
CompletionsFallbackStrategy::FilePaths
|
||||
)
|
||||
{
|
||||
if let Some(path_completion_context) = ctx.path_completion_context() {
|
||||
suggestions = sorted_paths_relative_to(
|
||||
parsed_argument,
|
||||
options.match_strategy,
|
||||
path_completion_context,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
suggestions
|
||||
}
|
||||
|
||||
/// Generate suggestions for the case where there was a parse error. Given there
|
||||
/// was a parse error, there could be a few possibilities for options we'd want
|
||||
/// to suggest based on the error from the parser:
|
||||
/// 1) MissingMandatoryPositional: The command is missing a mandatory argument
|
||||
/// at a certain index. In this case, we read the index from the error and
|
||||
/// execute `complete` on the argument to get the possible suggestions for the
|
||||
/// argument.
|
||||
/// 2) MissingValueForName: A flag was supplied without a corresponding value, in
|
||||
/// this case we suggest the possible arguments for the option that is missing.
|
||||
/// 3) Unexpected argument: An extra argument was supplied. This extra argument could be the prefix
|
||||
/// of a subcommand, so we suggest subcommands that start with the value of the extra argument
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn suggestions_for_parse_error(
|
||||
input: &str,
|
||||
tokens_without_last_editing: &[&str],
|
||||
cursor: &Span,
|
||||
root_err: ParseError,
|
||||
shell_command: &mut ShellCommand,
|
||||
command_signature: &Command,
|
||||
session_env_vars: Option<&HashMap<String, String>>,
|
||||
options: &CompleterOptions,
|
||||
ctx: &dyn CompletionContext,
|
||||
) -> (Vec<MatchedSuggestion>, bool) {
|
||||
match root_err.reason {
|
||||
ParseErrorReason::ArgumentError {
|
||||
command: _,
|
||||
error:
|
||||
MissingValueForName {
|
||||
name,
|
||||
missing_arg_index,
|
||||
},
|
||||
} => {
|
||||
// If there was trailing whitespace in the line, respect the error and try to to complete based
|
||||
// on the missing argument. If there wasn't any trailing whitespace, the user is trying
|
||||
// to complete an argument before the one that's missing (such as `git push ori<tab>`) so we
|
||||
// treat this as successful parse so that we can parse out the argument correctly.
|
||||
if shell_command.args.ending_whitespace.is_some() {
|
||||
let mut arg_has_spec = false;
|
||||
|
||||
let argument = command_signature
|
||||
.options
|
||||
.iter()
|
||||
.find(|opt| opt.has_name(&*name))
|
||||
.and_then(|opt| opt.arguments.get(missing_arg_index));
|
||||
|
||||
let results = match argument {
|
||||
None => Default::default(),
|
||||
Some(arg) => {
|
||||
// Complete on the exact missing argument for the flag
|
||||
// (rather than combining the completions for _all_ of the flags' args)
|
||||
arg_has_spec = true;
|
||||
generate_suggestions_for_argument(
|
||||
arg,
|
||||
&ParsedToken::empty(),
|
||||
tokens_without_last_editing,
|
||||
session_env_vars,
|
||||
options,
|
||||
ctx,
|
||||
)
|
||||
.await
|
||||
}
|
||||
};
|
||||
|
||||
return (results, arg_has_spec);
|
||||
} else {
|
||||
return suggestions_for_last_argument(
|
||||
tokens_without_last_editing,
|
||||
shell_command,
|
||||
cursor,
|
||||
command_signature,
|
||||
session_env_vars,
|
||||
options,
|
||||
ctx,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
ParseErrorReason::ArgumentError {
|
||||
command: _,
|
||||
error:
|
||||
MissingMandatoryPositional {
|
||||
name: _name,
|
||||
positional_index,
|
||||
},
|
||||
} => {
|
||||
// If there was ending whitespace in the line respect the error and try to to complete based
|
||||
// on the missing positional. If there was not an ending whitespace, the user is try trying
|
||||
// to complete a positional before the one that's missing such as `git push ori<tab>` so we
|
||||
// treat this as successful parse so that we can parse out the positional correctly.
|
||||
if shell_command.args.ending_whitespace.is_some() {
|
||||
add_extra_positional(shell_command, cursor);
|
||||
let argument = command_signature
|
||||
.arguments
|
||||
.get(positional_index)
|
||||
.expect("argument should exist based on error from parser");
|
||||
let results = generate_suggestions_for_argument(
|
||||
argument,
|
||||
&ParsedToken::empty(),
|
||||
tokens_without_last_editing,
|
||||
session_env_vars,
|
||||
options,
|
||||
ctx,
|
||||
)
|
||||
.await;
|
||||
return (results, true);
|
||||
} else {
|
||||
return suggestions_for_last_argument(
|
||||
tokens_without_last_editing,
|
||||
shell_command,
|
||||
cursor,
|
||||
command_signature,
|
||||
session_env_vars,
|
||||
options,
|
||||
ctx,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
ParseErrorReason::ArgumentError {
|
||||
command: _,
|
||||
error: UnexpectedArgument(arg),
|
||||
} => {
|
||||
if arg.span.end() == input.len() {
|
||||
// The unexpected argument could be a prefix for a subcommand.
|
||||
let prefix = arg.item.as_str();
|
||||
let results = (command_signature.subcommands.iter().filter_map(|subcmd| {
|
||||
options
|
||||
.match_strategy
|
||||
.get_match_type(prefix, subcmd.name.as_str())
|
||||
.map(|match_type| {
|
||||
let suggestion = Suggestion::with_same_display_and_replacement(
|
||||
subcmd.name.clone(),
|
||||
subcmd.description.as_ref().cloned(),
|
||||
SuggestionType::Subcommand,
|
||||
subcmd.priority.into(),
|
||||
);
|
||||
MatchedSuggestion::new(suggestion, match_type)
|
||||
})
|
||||
}))
|
||||
.sorted_by(MatchedSuggestion::cmp_by_display)
|
||||
.collect();
|
||||
return (results, false);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
(Default::default(), false)
|
||||
}
|
||||
|
||||
/// Finds the last positional or named argument (an argument for a flag) in the line and generates
|
||||
/// suggestions based on the type of the argument.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn suggestions_for_last_argument(
|
||||
tokens_without_last_editing: &[&str],
|
||||
shell_command: &mut ShellCommand,
|
||||
cursor: &Span,
|
||||
command_signature: &Command,
|
||||
session_env_vars: Option<&HashMap<String, String>>,
|
||||
options: &CompleterOptions,
|
||||
ctx: &dyn CompletionContext,
|
||||
) -> (Vec<MatchedSuggestion>, bool) {
|
||||
if shell_command.args.ending_whitespace.is_some() {
|
||||
add_extra_positional(shell_command, cursor);
|
||||
}
|
||||
|
||||
// Find the last positional and named value within the the command that the user entered.
|
||||
// Whichever ends last is the value we're trying to complete on.
|
||||
let last_positional = shell_command.last_positional();
|
||||
let last_named_value = shell_command.last_named_argument();
|
||||
|
||||
let results = match (last_positional, last_named_value) {
|
||||
(Some(last_positional), Some(last_named_arg)) => {
|
||||
if last_positional.span.end() > last_named_arg.span.end() {
|
||||
// If there is a positional and a named argument (option), we want to
|
||||
// complete on the option's arguments only if the option is variadic.
|
||||
// Otherwise, the option's arguments are already satisfied and we
|
||||
// should complete the positional.
|
||||
// TODO(CORE-646): If the option is variadic, the user could be trying
|
||||
// to complete the option's arguments or the positional argument, so
|
||||
// we should show suggestions for both.
|
||||
let option_with_arguments = command_signature
|
||||
.options
|
||||
.iter()
|
||||
.find(|opt| opt.has_name(last_named_arg.item.name))
|
||||
.filter(|opt| opt.arguments.iter().any(|arg| arg.is_variadic()));
|
||||
let arguments = option_with_arguments
|
||||
.map_or(&command_signature.arguments, |opt| &opt.arguments);
|
||||
|
||||
complete_positional(
|
||||
&shell_command,
|
||||
arguments,
|
||||
&command_signature.subcommands,
|
||||
last_positional.item,
|
||||
tokens_without_last_editing,
|
||||
session_env_vars,
|
||||
options,
|
||||
ctx,
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
complete_option(
|
||||
command_signature,
|
||||
last_named_arg.item.name,
|
||||
last_named_arg.item.parsed_token,
|
||||
tokens_without_last_editing,
|
||||
session_env_vars,
|
||||
options,
|
||||
ctx,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
(Some(last_positional), None) => {
|
||||
complete_positional(
|
||||
&shell_command,
|
||||
&command_signature.arguments,
|
||||
&command_signature.subcommands,
|
||||
last_positional.item,
|
||||
tokens_without_last_editing,
|
||||
session_env_vars,
|
||||
options,
|
||||
ctx,
|
||||
)
|
||||
.await
|
||||
}
|
||||
(None, Some(last_named_arg)) => {
|
||||
complete_option(
|
||||
command_signature,
|
||||
last_named_arg.item.name,
|
||||
last_named_arg.item.parsed_token,
|
||||
tokens_without_last_editing,
|
||||
session_env_vars,
|
||||
options,
|
||||
ctx,
|
||||
)
|
||||
.await
|
||||
}
|
||||
(None, None) => Default::default(),
|
||||
};
|
||||
|
||||
(results, true)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn complete_option(
|
||||
command_signature: &Command,
|
||||
option_name: &str,
|
||||
parsed_token: &ParsedToken,
|
||||
tokens_without_last_editing: &[&str],
|
||||
session_env_vars: Option<&HashMap<String, String>>,
|
||||
options: &CompleterOptions,
|
||||
ctx: &dyn CompletionContext,
|
||||
) -> Vec<MatchedSuggestion> {
|
||||
let last_argument = command_signature
|
||||
.options
|
||||
.iter()
|
||||
.find(|opt| opt.has_name(option_name))
|
||||
.and_then(|opt| opt.arguments.last());
|
||||
|
||||
match last_argument {
|
||||
None => Default::default(),
|
||||
Some(argument) => {
|
||||
generate_suggestions_for_argument(
|
||||
argument,
|
||||
parsed_token,
|
||||
tokens_without_last_editing,
|
||||
session_env_vars,
|
||||
options,
|
||||
ctx,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn complete_positional(
|
||||
shell_command: &&mut ShellCommand,
|
||||
arguments: &[Argument],
|
||||
subcommands: &[Command],
|
||||
parsed_token: &ParsedToken,
|
||||
tokens_without_last_editing: &[&str],
|
||||
session_env_vars: Option<&HashMap<String, String>>,
|
||||
options: &CompleterOptions,
|
||||
ctx: &dyn CompletionContext,
|
||||
) -> Vec<MatchedSuggestion> {
|
||||
let mut suggestions = Default::default();
|
||||
// Whether we should suggest subcommands or continue to complete on positionals for the current
|
||||
// command.
|
||||
let mut should_suggest_subcommands = true;
|
||||
|
||||
if let Some(positionals) = &shell_command.args.positionals.as_ref() {
|
||||
if !arguments.is_empty() {
|
||||
let positional_index = positionals.len() - 1;
|
||||
|
||||
let arg = match arguments
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find(|(idx, arg)| idx <= &positional_index && arg.is_variadic())
|
||||
{
|
||||
None => arguments.get(positionals.len() - 1),
|
||||
Some((_, arg)) => {
|
||||
// If the argument is optional, it's possible the user is actually trying to
|
||||
// enter a subcommand.
|
||||
should_suggest_subcommands = arg.optional;
|
||||
Some(arg)
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(arg) = arg {
|
||||
suggestions = generate_suggestions_for_argument(
|
||||
arg,
|
||||
parsed_token,
|
||||
tokens_without_last_editing,
|
||||
session_env_vars,
|
||||
options,
|
||||
ctx,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Append subcommand suggestions to all argument suggestions.
|
||||
if should_suggest_subcommands {
|
||||
suggestions.extend(
|
||||
subcommands
|
||||
.iter()
|
||||
.filter_map(|subcmd| {
|
||||
options
|
||||
.match_strategy
|
||||
.get_match_type(parsed_token.as_str(), subcmd.name.as_str())
|
||||
.map(|match_type| {
|
||||
let suggestion = Suggestion::with_same_display_and_replacement(
|
||||
subcmd.name.clone(),
|
||||
subcmd.description.as_ref().cloned(),
|
||||
SuggestionType::Subcommand,
|
||||
subcmd.priority.into(),
|
||||
);
|
||||
MatchedSuggestion::new(suggestion, match_type)
|
||||
})
|
||||
})
|
||||
.sorted_by(MatchedSuggestion::cmp_by_display),
|
||||
);
|
||||
}
|
||||
suggestions
|
||||
}
|
||||
|
||||
async fn generate_suggestions_for_argument(
|
||||
argument: &Argument,
|
||||
parsed_token: &ParsedToken,
|
||||
tokens_without_last_editing: &[&str],
|
||||
session_env_vars: Option<&HashMap<String, String>>,
|
||||
options: &CompleterOptions,
|
||||
ctx: &dyn CompletionContext,
|
||||
) -> Vec<MatchedSuggestion> {
|
||||
if argument.values.is_empty()
|
||||
&& matches!(
|
||||
options.fallback_strategy,
|
||||
CompletionsFallbackStrategy::FilePaths
|
||||
)
|
||||
{
|
||||
// If the signature has an argument but no argument type to generate suggestions,
|
||||
// fallback to the fallback type if any.
|
||||
return match ctx.path_completion_context() {
|
||||
Some(path_completion_context) => {
|
||||
sorted_paths_relative_to(
|
||||
parsed_token,
|
||||
options.match_strategy,
|
||||
path_completion_context,
|
||||
)
|
||||
.await
|
||||
}
|
||||
None => Default::default(),
|
||||
};
|
||||
}
|
||||
|
||||
// Ideally we could run these in parallel via `join_all`. However, this can cause problems
|
||||
// over SSH because a remote box could have a value of `MaxSessions` (the max number of open
|
||||
// sessions for a single connection) that is less than the number of arguments that we would
|
||||
// be trying to generate in parallel. Functionally, this would result in `channel: open
|
||||
// failed` messages sent back over the PTY in the running sessions.
|
||||
// TODO(alokedesai): Consider using `join_all` for local sessions.
|
||||
let run_in_parallel = ctx
|
||||
.generator_context()
|
||||
.is_some_and(GeneratorContext::supports_parallel_execution);
|
||||
|
||||
if run_in_parallel {
|
||||
let par_iterator = argument.values.iter().map(|argument_value| {
|
||||
generate_suggestions_for_argument_value(
|
||||
argument_value,
|
||||
parsed_token,
|
||||
tokens_without_last_editing,
|
||||
session_env_vars,
|
||||
options.match_strategy,
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
futures::future::join_all(par_iterator)
|
||||
.await
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect()
|
||||
} else {
|
||||
// These are processed in the order that argument.argument_types is defined
|
||||
// (https://github.com/warpdotdev/command-signatures/blob/5e89fb22995cd5ca9f5609d75193018a2a194c59/completion-metadata/src/fig_types.rs#L288).
|
||||
// That's why we can just flat map here without thinking about order.
|
||||
// Even if there are multiple generators, they will appear one after the other and we will
|
||||
// simply concatenate their results (extracting the non-default priorities).
|
||||
let mut completion_results = Vec::new();
|
||||
for argument_value in &argument.values {
|
||||
let matched_suggestions = generate_suggestions_for_argument_value(
|
||||
argument_value,
|
||||
parsed_token,
|
||||
tokens_without_last_editing,
|
||||
session_env_vars,
|
||||
options.match_strategy,
|
||||
ctx,
|
||||
)
|
||||
.await;
|
||||
completion_results.extend(matched_suggestions)
|
||||
}
|
||||
|
||||
completion_results
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn generate_suggestions_for_argument_value(
|
||||
argument_value: &ArgumentValue,
|
||||
parsed_token: &ParsedToken,
|
||||
tokens_without_last_editing: &[&str],
|
||||
session_env_vars: Option<&HashMap<String, String>>,
|
||||
matcher: MatchStrategy,
|
||||
ctx: &dyn CompletionContext,
|
||||
) -> impl IntoIterator<Item = MatchedSuggestion> {
|
||||
// TODO(completions-v2): Implement generator support.
|
||||
match argument_value {
|
||||
ArgumentValue::Suggestion(suggestion) => {
|
||||
let warp_suggestion: Suggestion = suggestion.clone().into();
|
||||
match matcher.get_match_type(parsed_token.as_str(), warp_suggestion.display.as_str()) {
|
||||
Some(match_type) => vec![MatchedSuggestion::new(warp_suggestion, match_type)],
|
||||
None => vec![],
|
||||
}
|
||||
}
|
||||
// Even if the argument is just files, recommend both files and folders since the
|
||||
// user may want to choose a nested file.
|
||||
ArgumentValue::Template {
|
||||
type_name: TemplateType::FilesAndFolders,
|
||||
..
|
||||
}
|
||||
| ArgumentValue::Template {
|
||||
type_name: TemplateType::Files,
|
||||
..
|
||||
} => {
|
||||
let path_suggestions = match ctx.path_completion_context() {
|
||||
Some(path_completion_context) => {
|
||||
sorted_paths_relative_to(parsed_token, matcher, path_completion_context).await
|
||||
}
|
||||
None => Vec::new(),
|
||||
};
|
||||
|
||||
// TODO(completions-v2): Implement template filter functions.
|
||||
path_suggestions
|
||||
}
|
||||
ArgumentValue::Template {
|
||||
type_name: TemplateType::Folders,
|
||||
..
|
||||
} => {
|
||||
let path_suggestions = match ctx.path_completion_context() {
|
||||
Some(path_completion_context) => {
|
||||
sorted_directories_relative_to(parsed_token, matcher, path_completion_context)
|
||||
.await
|
||||
}
|
||||
None => Vec::new(),
|
||||
};
|
||||
|
||||
// TODO(completions-v2): Implement template filter functions.
|
||||
path_suggestions
|
||||
}
|
||||
ArgumentValue::Generator(GeneratorFn::Custom(js_fn)) => {
|
||||
let (Some(js_ctx), Some(path_ctx)) = (ctx.js_context(), ctx.path_completion_context())
|
||||
else {
|
||||
return vec![];
|
||||
};
|
||||
let input = GeneratorCompletionContext {
|
||||
tokens: tokens_without_last_editing
|
||||
.iter()
|
||||
.map(|token| token.to_string())
|
||||
.collect(),
|
||||
pwd: path_ctx.pwd().to_string_lossy().to_string(),
|
||||
};
|
||||
let Ok(output) = call_js_function(&input, js_fn, js_ctx).await else {
|
||||
return vec![];
|
||||
};
|
||||
let internal_suggestions = output
|
||||
.suggestions
|
||||
.into_iter()
|
||||
.map(Suggestion::from)
|
||||
.filter_map(|suggestion| {
|
||||
let match_type =
|
||||
matcher.get_match_type(parsed_token.as_str(), suggestion.display.as_str());
|
||||
match_type.map(|match_type| MatchedSuggestion::new(suggestion, match_type))
|
||||
});
|
||||
|
||||
if output.is_ordered {
|
||||
internal_suggestions.collect()
|
||||
} else {
|
||||
internal_suggestions
|
||||
.sorted_by(MatchedSuggestion::cmp_by_display)
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
ArgumentValue::Generator(GeneratorFn::ShellCommand {
|
||||
script,
|
||||
post_process,
|
||||
}) => {
|
||||
let (Some(js_ctx), Some(generator_ctx)) = (ctx.js_context(), ctx.generator_context())
|
||||
else {
|
||||
return vec![];
|
||||
};
|
||||
let script = match script {
|
||||
GeneratorScript::Static(script) => Cow::Borrowed(script),
|
||||
GeneratorScript::Dynamic(js_fn) => {
|
||||
let input: Vec<String> = tokens_without_last_editing
|
||||
.iter()
|
||||
.map(|token| token.to_string())
|
||||
.collect();
|
||||
let Ok(script) = call_js_function(&input, js_fn, js_ctx).await else {
|
||||
return vec![];
|
||||
};
|
||||
Cow::Owned(script)
|
||||
}
|
||||
};
|
||||
|
||||
let Ok(output) = generator_ctx
|
||||
.execute_command_at_pwd(script.as_str(), session_env_vars.cloned())
|
||||
.await
|
||||
else {
|
||||
return vec![];
|
||||
};
|
||||
|
||||
match output.status {
|
||||
CommandExitStatus::Success => {
|
||||
let Ok(output_string) = output.to_string() else {
|
||||
return vec![];
|
||||
};
|
||||
|
||||
let results = match post_process {
|
||||
Some(js_fn) => {
|
||||
let Ok(results) = call_js_function(&output_string, js_fn, js_ctx).await
|
||||
else {
|
||||
return vec![];
|
||||
};
|
||||
results
|
||||
}
|
||||
None => {
|
||||
let suggestions: Vec<signatures::Suggestion> = output_string
|
||||
.lines()
|
||||
.map(|line| signatures::Suggestion {
|
||||
value: line.to_owned(),
|
||||
display_value: None,
|
||||
description: None,
|
||||
priority: signatures::Priority::default(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
GeneratorResults {
|
||||
suggestions,
|
||||
is_ordered: true,
|
||||
}
|
||||
}
|
||||
};
|
||||
let internal_suggestions = results
|
||||
.suggestions
|
||||
.into_iter()
|
||||
.map(Into::into)
|
||||
.filter_map(|suggestion: Suggestion| {
|
||||
let match_type = matcher
|
||||
.get_match_type(parsed_token.as_str(), suggestion.display.as_str());
|
||||
match_type
|
||||
.map(|match_type| MatchedSuggestion::new(suggestion, match_type))
|
||||
});
|
||||
|
||||
if results.is_ordered {
|
||||
internal_suggestions.collect()
|
||||
} else {
|
||||
internal_suggestions
|
||||
.sorted_by(MatchedSuggestion::cmp_by_display)
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
CommandExitStatus::Failure => {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// TODO(completions-v2): Implement suggestion generation for ArgumentValue::RootCommand.
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<signatures::Suggestion> for Suggestion {
|
||||
fn from(suggestion: signatures::Suggestion) -> Self {
|
||||
let replacement: SmolStr = suggestion.value.into();
|
||||
let display = suggestion
|
||||
.display_value
|
||||
.map(Into::into)
|
||||
.unwrap_or_else(|| replacement.clone());
|
||||
Self {
|
||||
display,
|
||||
replacement,
|
||||
description: suggestion.description,
|
||||
suggestion_type: SuggestionType::Argument,
|
||||
file_type: None,
|
||||
is_abbreviation: false,
|
||||
priority: suggestion.priority.into(),
|
||||
// TODO(completions-v2): Implement these fields for V2 Suggestions.
|
||||
override_icon: None,
|
||||
is_hidden: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Suggestion> for signatures::Suggestion {
|
||||
fn from(suggestion: Suggestion) -> signatures::Suggestion {
|
||||
signatures::Suggestion {
|
||||
value: suggestion.replacement.into(),
|
||||
display_value: Some(suggestion.display.into()),
|
||||
description: suggestion.description,
|
||||
priority: suggestion.priority.into(),
|
||||
// TODO(completions-v2): Implement these fields for V2 Suggestions.
|
||||
// icon: suggestion.override_icon,
|
||||
// is_hidden: suggestion.is_hidden,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
use itertools::Itertools;
|
||||
|
||||
use crate::completer::{
|
||||
context::CompletionContext,
|
||||
engine, get_path_separators,
|
||||
matchers::MatchStrategy,
|
||||
suggest::{MatchedSuggestion, Priority, Suggestion, SuggestionType},
|
||||
TopLevelCommandCaseSensitivity,
|
||||
};
|
||||
use crate::parsers::ParsedToken;
|
||||
|
||||
use super::path::{sorted_directories_relative_to, sorted_paths_relative_to};
|
||||
|
||||
/// Generates top-level completion results based on the fragment of text that is entered into the
|
||||
/// buffer. We use the following algorithm to generate suggestions, which is also the same as ZSH:
|
||||
/// 1) If the fragment is clearly a path prefix (./, .., etc), _only_ suggest files.
|
||||
/// 2) If the fragment starts with a `$`, _only_ suggest environment variables.
|
||||
/// 3) Otherwise, suggest all top-level executables the user can run. If the shell has
|
||||
/// "autocd" enabled (cd'ing into a directory without specifying the `cd` command) also suggest
|
||||
/// filepaths.
|
||||
pub async fn complete(
|
||||
context: &dyn CompletionContext,
|
||||
matcher: MatchStrategy,
|
||||
parsed_token: &ParsedToken,
|
||||
) -> Vec<MatchedSuggestion> {
|
||||
// If the command trying to be matched contains "/", we're actually in a place where we need
|
||||
// to be suggesting paths instead of trying to read the command registry at all.
|
||||
if parsed_token
|
||||
.as_str()
|
||||
.contains(get_path_separators(context).all)
|
||||
{
|
||||
return match context.path_completion_context() {
|
||||
Some(path_completion_context) => {
|
||||
sorted_paths_relative_to(parsed_token, matcher, path_completion_context)
|
||||
.await
|
||||
.into_iter()
|
||||
.collect()
|
||||
}
|
||||
None => Default::default(),
|
||||
};
|
||||
}
|
||||
|
||||
// The buffer starts with a `$`, suggest environment variables.
|
||||
if parsed_token.as_str().starts_with('$') {
|
||||
return if let Some(env_vars) = context.environment_variable_names() {
|
||||
engine::variable_suggestions(matcher.to_owned(), env_vars, parsed_token)
|
||||
} else {
|
||||
Default::default()
|
||||
};
|
||||
}
|
||||
|
||||
let command_suggestions = sorted_top_level_commands(parsed_token.as_str(), matcher, context);
|
||||
|
||||
// If `cd`ing into a directory without entering `cd` is enabled, also suggest directories.
|
||||
// Note that the directories will be listed _after_ the top level commands.
|
||||
if context.shell_supports_autocd().unwrap_or(false) {
|
||||
if let Some(path_completion_context) = context.path_completion_context() {
|
||||
return command_suggestions
|
||||
.into_iter()
|
||||
.chain(
|
||||
sorted_directories_relative_to(parsed_token, matcher, path_completion_context)
|
||||
.await
|
||||
.into_iter(),
|
||||
)
|
||||
.collect();
|
||||
}
|
||||
}
|
||||
|
||||
command_suggestions.into_iter().collect()
|
||||
}
|
||||
|
||||
/// Returns a lexicographically ordered `Vec` of suggestions for top-level commands based on the
|
||||
/// `partial` buffer and the `context`'s top-level commands, aliases, and abbreviations.
|
||||
fn sorted_top_level_commands(
|
||||
partial: &str,
|
||||
matcher: MatchStrategy,
|
||||
context: &dyn CompletionContext,
|
||||
) -> Vec<MatchedSuggestion> {
|
||||
context
|
||||
.top_level_commands()
|
||||
.filter_map(|command| {
|
||||
matcher.get_match_type(partial, command).map(|match_type| {
|
||||
let suggestion = command_suggestion(command, context).unwrap_or_else(|| {
|
||||
Suggestion::with_same_display_and_replacement(
|
||||
command,
|
||||
None,
|
||||
SuggestionType::Command(context.command_case_sensitivity()),
|
||||
Priority::default(),
|
||||
)
|
||||
});
|
||||
MatchedSuggestion::new(suggestion, match_type)
|
||||
})
|
||||
})
|
||||
.sorted_by(MatchedSuggestion::cmp_by_display)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Return a top-level command's `Suggestion`
|
||||
fn command_suggestion(command: &str, context: &dyn CompletionContext) -> Option<Suggestion> {
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(feature = "v2")] {
|
||||
let command_suggestion =
|
||||
context
|
||||
.command_registry()
|
||||
.get_signature(command)
|
||||
.map(|signature| {
|
||||
Suggestion::with_same_display_and_replacement(
|
||||
signature.command.name.clone(),
|
||||
signature.command.description.as_ref().cloned(),
|
||||
// TODO(CORE-2795) This needs to be overrideable by
|
||||
// signature.parser_directives.always_case_insensitive
|
||||
SuggestionType::Command(context.command_case_sensitivity()),
|
||||
signature.command.priority.into(),
|
||||
)
|
||||
|
||||
});
|
||||
} else {
|
||||
let command_suggestion =
|
||||
context
|
||||
.command_registry()
|
||||
.signature(command)
|
||||
.map(|signature| {
|
||||
let case_sensitivity = if signature.parser_directives.always_case_insensitive {
|
||||
TopLevelCommandCaseSensitivity::CaseInsensitive
|
||||
} else {
|
||||
context.command_case_sensitivity()
|
||||
};
|
||||
Suggestion::with_same_display_and_replacement(
|
||||
signature.name.clone(),
|
||||
signature.description.as_ref().cloned(),
|
||||
SuggestionType::Command(case_sensitivity),
|
||||
signature.priority.into(),
|
||||
)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
command_suggestion
|
||||
.or_else(|| alias_suggestion(command, context))
|
||||
.or_else(|| function_suggestion(command, context))
|
||||
.or_else(|| builtin_suggestion(command, context))
|
||||
.or_else(|| abbr_suggestion(command, context))
|
||||
}
|
||||
|
||||
fn alias_suggestion(command: &str, context: &dyn CompletionContext) -> Option<Suggestion> {
|
||||
context.alias_command(command).map(|value| {
|
||||
Suggestion::with_same_display_and_replacement(
|
||||
command,
|
||||
Some(format!("Alias for \"{value}\"")),
|
||||
SuggestionType::Command(context.alias_and_function_case_sensitivity()),
|
||||
Priority::default(),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn function_suggestion(command: &str, context: &dyn CompletionContext) -> Option<Suggestion> {
|
||||
context.functions()?.get(command).map(|_| {
|
||||
Suggestion::with_same_display_and_replacement(
|
||||
command,
|
||||
Some("Shell function".to_string()),
|
||||
SuggestionType::Command(context.alias_and_function_case_sensitivity()),
|
||||
Priority::default(),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn builtin_suggestion(command: &str, context: &dyn CompletionContext) -> Option<Suggestion> {
|
||||
context.builtins()?.get(command).map(|_| {
|
||||
Suggestion::with_same_display_and_replacement(
|
||||
command,
|
||||
Some("Shell builtin".to_string()),
|
||||
SuggestionType::Command(TopLevelCommandCaseSensitivity::CaseSensitive),
|
||||
Priority::default(),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn abbr_suggestion(command: &str, context: &dyn CompletionContext) -> Option<Suggestion> {
|
||||
context
|
||||
.abbreviations()?
|
||||
.get(command)
|
||||
.map(|value| Suggestion::new_for_abbreviation(command, value, Priority::default()))
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
//! Contains the legacy implementation of flag suggestion generation that depends on the legacy
|
||||
//! command signature struct (`warp_command_signatures::Signature`).
|
||||
use itertools::Itertools;
|
||||
use warp_command_signatures::{FlagStyle, Signature as SpecSignature};
|
||||
|
||||
use crate::completer::{
|
||||
describe::OptionCaseSensitivity,
|
||||
engine::LocationType,
|
||||
matchers::{Match, MatchStrategy},
|
||||
suggest::{MatchRequirement, MatchedSuggestion, Suggestion, SuggestionType},
|
||||
};
|
||||
use crate::meta::Spanned;
|
||||
use crate::parsers::SignatureAtTokenIndex;
|
||||
|
||||
/// Returns suggestions for short hand flags (i.e. flags that are of the form '-X').
|
||||
/// We currently omit surfacing flags that are already in partial_without_dashes, except
|
||||
/// for the most recent one (i.e. with `-abc`, we would omit `-a` and `-b` but include
|
||||
/// `-c`), which is used for the Describe API.
|
||||
fn short_hand_flag_suggestions(
|
||||
signature: &SpecSignature,
|
||||
partial_without_dashes: &str,
|
||||
) -> impl Iterator<Item = MatchedSuggestion> {
|
||||
signature
|
||||
.short_hand_flags()
|
||||
.filter_map(|flag| {
|
||||
// Since short hand flags can be written one after the other
|
||||
// (e.g. ssh -Xv), we can't just prefix match the flag against partial.
|
||||
// Instead, the logic below assumes that a short hand flag can only be used once.
|
||||
// Note that this is not however true in the real world (e.g. ssh -vv
|
||||
// is valid). We should eventually use the completions spec to figure out
|
||||
// how many times an option can be repeated in a given command.
|
||||
let is_flag_already_included = partial_without_dashes.contains(flag.name);
|
||||
// We want to include the flag if it is the most recent one the user has
|
||||
// typed, so we can provide a suggestion for the current short hand flags.
|
||||
// This suggestion is used so we have an entry for the current flags in the
|
||||
// completions menu, and also in our Describe API.
|
||||
let is_current_flag = partial_without_dashes
|
||||
.chars()
|
||||
.last()
|
||||
.is_some_and(|c| c.to_string() == flag.name);
|
||||
let should_include_flag = !is_flag_already_included || is_current_flag;
|
||||
should_include_flag.then(|| {
|
||||
let replacement_text = if is_current_flag {
|
||||
// The replacement text should be exactly the existing flags.
|
||||
format!("-{partial_without_dashes}")
|
||||
} else {
|
||||
// The replacement text should be the existing flags followed by the new one.
|
||||
format!("-{}{}", partial_without_dashes, flag.name)
|
||||
};
|
||||
|
||||
let case_sensitivity = if signature.parser_directives.always_case_insensitive {
|
||||
OptionCaseSensitivity::CaseInsensitive
|
||||
} else {
|
||||
OptionCaseSensitivity::CaseSensitive
|
||||
};
|
||||
|
||||
let suggestion = Suggestion::new(
|
||||
format!("-{}", flag.name),
|
||||
replacement_text,
|
||||
flag.description.map(Into::into),
|
||||
SuggestionType::Option(MatchRequirement::EntireName, case_sensitivity),
|
||||
flag.priority.into(),
|
||||
);
|
||||
|
||||
MatchedSuggestion {
|
||||
suggestion,
|
||||
match_type: Match::Prefix {
|
||||
is_case_sensitive: true,
|
||||
},
|
||||
}
|
||||
})
|
||||
})
|
||||
.sorted_by(MatchedSuggestion::cmp_by_display)
|
||||
}
|
||||
|
||||
/// Returns suggestions for long hand flags (i.e. flags that are of the form '--flag-name'
|
||||
/// or -flagname') that begin with '--<partial_without_dashes>'.
|
||||
///
|
||||
/// If set, `style` filters long flags by their style - for example,
|
||||
/// `Some(FlagStyle::SingleDash)` if suggesting for `-<partial_without_dashes>`.
|
||||
fn long_hand_flag_suggestions(
|
||||
signature: &SpecSignature,
|
||||
matcher: MatchStrategy,
|
||||
partial_without_dashes: &str,
|
||||
style: Option<FlagStyle>,
|
||||
) -> impl Iterator<Item = MatchedSuggestion> {
|
||||
signature
|
||||
.long_hand_flags()
|
||||
.filter(|flag| style.is_none_or(|style| flag.style == style))
|
||||
.filter_map(|flag| {
|
||||
matcher
|
||||
.get_match_type(partial_without_dashes, flag.name)
|
||||
.map(|match_type| {
|
||||
let name = match flag.style {
|
||||
FlagStyle::SingleDash => format!("-{}", flag.name),
|
||||
FlagStyle::DoubleDash => format!("--{}", flag.name),
|
||||
};
|
||||
|
||||
let match_requirement = if signature.parser_directives.flags_match_unique_prefix
|
||||
{
|
||||
MatchRequirement::UniquePrefixOnly
|
||||
} else {
|
||||
MatchRequirement::EntireName
|
||||
};
|
||||
|
||||
let case_sensitivity = if signature.parser_directives.always_case_insensitive {
|
||||
OptionCaseSensitivity::CaseInsensitive
|
||||
} else {
|
||||
OptionCaseSensitivity::CaseSensitive
|
||||
};
|
||||
|
||||
let suggestion = Suggestion::with_same_display_and_replacement(
|
||||
name,
|
||||
flag.description.map(Into::into),
|
||||
SuggestionType::Option(match_requirement, case_sensitivity),
|
||||
flag.priority.into(),
|
||||
);
|
||||
MatchedSuggestion::new(suggestion, match_type)
|
||||
})
|
||||
})
|
||||
.sorted_by(MatchedSuggestion::cmp_by_display)
|
||||
}
|
||||
|
||||
pub fn complete(
|
||||
matcher: MatchStrategy,
|
||||
location: &Spanned<LocationType>,
|
||||
found_signature: Option<SignatureAtTokenIndex>,
|
||||
) -> Vec<MatchedSuggestion> {
|
||||
if let Spanned {
|
||||
item: LocationType::Flag {
|
||||
flag_name: name, ..
|
||||
},
|
||||
..
|
||||
} = location
|
||||
{
|
||||
if let Some(found_signature) = found_signature {
|
||||
let name = name.as_ref().map(|name| &name.item);
|
||||
return match name {
|
||||
// Case 1: if we are completing on '--<partial>', we surface long hand flags that begin with partial
|
||||
Some(long) if long.starts_with("--") => long_hand_flag_suggestions(
|
||||
found_signature.signature,
|
||||
matcher,
|
||||
&long[2..],
|
||||
Some(FlagStyle::DoubleDash),
|
||||
)
|
||||
.collect(),
|
||||
// Case 2: if we are completing on a single '-', we surface all short hand flags followed by all long hand flags
|
||||
Some(short) if short == "-" => {
|
||||
short_hand_flag_suggestions(found_signature.signature, "")
|
||||
.chain(long_hand_flag_suggestions(
|
||||
found_signature.signature,
|
||||
matcher,
|
||||
"",
|
||||
None,
|
||||
))
|
||||
.collect()
|
||||
}
|
||||
// Case 3: if we are completing on '-<partial>', we surface short hand flags that begin with partial,
|
||||
// followed by long hand flags that begin with partial.
|
||||
Some(short) if short.starts_with('-') => {
|
||||
short_hand_flag_suggestions(found_signature.signature, &short[1..])
|
||||
.chain(long_hand_flag_suggestions(
|
||||
found_signature.signature,
|
||||
matcher,
|
||||
&short[1..],
|
||||
Some(FlagStyle::SingleDash),
|
||||
))
|
||||
.collect()
|
||||
}
|
||||
// Case 4: if we are completing on whitespace (i.e. no prefix), we surface subcommands,
|
||||
// followed by all long hand flags, followed by all short hand flags
|
||||
None => long_hand_flag_suggestions(found_signature.signature, matcher, "", None)
|
||||
.chain(short_hand_flag_suggestions(found_signature.signature, ""))
|
||||
.collect(),
|
||||
_ => {
|
||||
log::info!("Reached option completion branch that should be unreachable");
|
||||
Default::default()
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Default::default()
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
#[cfg_attr(feature = "v2", path = "v2.rs")]
|
||||
#[cfg_attr(not(feature = "v2"), path = "legacy.rs")]
|
||||
mod imp;
|
||||
|
||||
pub use imp::*;
|
||||
@@ -0,0 +1,173 @@
|
||||
//! Contains the v2 implementation of flag suggestion generation that depends on the JS-compatible
|
||||
//! command signature struct (`crate::signatures::CommandSignature`).
|
||||
use std::cmp::Ordering;
|
||||
use std::iter;
|
||||
|
||||
use itertools::Itertools;
|
||||
|
||||
use crate::{
|
||||
completer::{
|
||||
describe::OptionCaseSensitivity, suggest::MatchRequirement, LocationType, Match,
|
||||
MatchStrategy, MatchedSuggestion, Suggestion, SuggestionType,
|
||||
},
|
||||
meta::Spanned,
|
||||
signatures::Command,
|
||||
};
|
||||
|
||||
pub fn complete(
|
||||
matcher: MatchStrategy,
|
||||
location: &Spanned<LocationType>,
|
||||
found_signature: Option<&Command>,
|
||||
) -> Vec<MatchedSuggestion> {
|
||||
let (
|
||||
Some(found_signature),
|
||||
Spanned {
|
||||
item:
|
||||
LocationType::Flag {
|
||||
flag_name: partial_flag_name,
|
||||
..
|
||||
},
|
||||
..
|
||||
},
|
||||
) = (found_signature, location)
|
||||
else {
|
||||
return Default::default();
|
||||
};
|
||||
|
||||
let suggestions = short_hand_flag_suggestions(
|
||||
found_signature,
|
||||
partial_flag_name
|
||||
.as_ref()
|
||||
.map(|name| name.item.as_str())
|
||||
.unwrap_or(""),
|
||||
matcher,
|
||||
);
|
||||
|
||||
let should_order_long_hand_before_short_hand = partial_flag_name.is_none();
|
||||
let sort_flag_suggestions = |a: &MatchedSuggestion, b: &MatchedSuggestion| -> Ordering {
|
||||
match (
|
||||
is_short_hand_flag_name(a.suggestion.display.as_str()),
|
||||
is_short_hand_flag_name(b.suggestion.display.as_str()),
|
||||
) {
|
||||
(true, false) => {
|
||||
if should_order_long_hand_before_short_hand {
|
||||
Ordering::Greater
|
||||
} else {
|
||||
Ordering::Less
|
||||
}
|
||||
}
|
||||
(false, true) => {
|
||||
if should_order_long_hand_before_short_hand {
|
||||
Ordering::Less
|
||||
} else {
|
||||
Ordering::Greater
|
||||
}
|
||||
}
|
||||
(_, _) => a.cmp_by_display(b),
|
||||
}
|
||||
};
|
||||
|
||||
suggestions.sorted_by(sort_flag_suggestions).collect()
|
||||
}
|
||||
|
||||
/// Returns suggestions for short hand flags (i.e. flags that are of the form '-X').
|
||||
///
|
||||
/// Short-hand flags included in `input_token` are not included in returned suggestions since
|
||||
/// they've already been specified, except for the last specified flag (if any), since the
|
||||
/// completion engine always returns the suggestion for the 'current token' if there is an exact
|
||||
/// match, (which in that case would be the last short-hand flag in the token).
|
||||
fn short_hand_flag_suggestions(
|
||||
command_signature: &Command,
|
||||
input_token: &str,
|
||||
matcher: MatchStrategy,
|
||||
) -> Box<dyn Iterator<Item = MatchedSuggestion>> {
|
||||
let (prefix, partial_flag_name) =
|
||||
if let Some(partial_flag_name) = input_token.strip_prefix("--") {
|
||||
("--", partial_flag_name)
|
||||
} else if let Some(partial_flag_name) = input_token.strip_prefix('-') {
|
||||
("-", partial_flag_name)
|
||||
} else if input_token.is_empty() {
|
||||
("", "")
|
||||
} else {
|
||||
return Box::new(iter::empty());
|
||||
};
|
||||
|
||||
Box::new(
|
||||
command_signature
|
||||
.options
|
||||
.iter()
|
||||
.flat_map(|option| option.name.iter().map(move |name| (option, name)))
|
||||
.filter(|(_, name)| name.starts_with(prefix))
|
||||
.filter_map(|(option, name)| {
|
||||
if is_short_hand_flag_name(name) {
|
||||
// Multiple short-hand flags can be specified in a single token with a leading
|
||||
// '-' (e.g. ssh -Xv). Do not suggest a shorthand flag if it has already been
|
||||
// specified unless it is the last flag in the current token; the completion engine
|
||||
// always returns a suggestion if it exactly matches the current token.
|
||||
let name_without_prefix = name.trim_start_matches('-');
|
||||
let is_flag_already_included = partial_flag_name.contains(name_without_prefix);
|
||||
let is_current_flag = partial_flag_name
|
||||
.chars()
|
||||
.last()
|
||||
.is_some_and(|c| c.to_string() == name_without_prefix);
|
||||
(!is_flag_already_included || is_current_flag).then(|| {
|
||||
let replacement_text = if is_current_flag {
|
||||
// The replacement text should be exactly the existing flags.
|
||||
format!("-{partial_flag_name}")
|
||||
} else {
|
||||
// The replacement text should be the existing flags followed by the new one.
|
||||
format!("-{partial_flag_name}{name_without_prefix}")
|
||||
};
|
||||
MatchedSuggestion {
|
||||
suggestion: Suggestion::new(
|
||||
name,
|
||||
replacement_text,
|
||||
option.description.clone(),
|
||||
// TODO(CORE-2795)
|
||||
SuggestionType::Option(
|
||||
MatchRequirement::EntireName,
|
||||
OptionCaseSensitivity::CaseSensitive,
|
||||
),
|
||||
option.priority.into(),
|
||||
),
|
||||
match_type: Match::Prefix {
|
||||
is_case_sensitive: true,
|
||||
},
|
||||
}
|
||||
})
|
||||
} else if is_long_hand_flag_name(name) {
|
||||
matcher.get_match_type(input_token, name).map(|match_type| {
|
||||
let suggestion = Suggestion::with_same_display_and_replacement(
|
||||
name,
|
||||
option.description.clone(),
|
||||
// TODO(CORE-2795)
|
||||
SuggestionType::Option(
|
||||
MatchRequirement::EntireName,
|
||||
OptionCaseSensitivity::CaseSensitive,
|
||||
),
|
||||
option.priority.into(),
|
||||
);
|
||||
MatchedSuggestion::new(suggestion, match_type)
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.sorted_by(MatchedSuggestion::cmp_by_display),
|
||||
)
|
||||
}
|
||||
|
||||
/// Heuristic to determine if a flag name is a short-hand flag or not.
|
||||
///
|
||||
/// * A single dash followed by a single character (`-h`, `-v`, etc.) is short-hand, unless the second character is also a dash.
|
||||
/// * A single dash followed by multiple characters (`-version`) is long-hand
|
||||
/// * Two dashes followed by 0 or more characters is long-hand
|
||||
fn is_short_hand_flag_name(name: &str) -> bool {
|
||||
name.len() == 2 && name.starts_with('-') && name != "--"
|
||||
}
|
||||
|
||||
/// Tests if `name` is a long-hand flag name. A long-hand flag is a string
|
||||
/// starting with `-` that is not a short-hand flag.
|
||||
fn is_long_hand_flag_name(name: &str) -> bool {
|
||||
name.starts_with('-') && !is_short_hand_flag_name(name)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
use crate::{completer::TopLevelCommandCaseSensitivity, signatures::CommandRegistry};
|
||||
|
||||
/// Returns the name of the argument that should be given at `idx` for the given command.
|
||||
pub(super) fn argument_name_at_index_for_command(
|
||||
command: &str,
|
||||
idx: usize,
|
||||
command_registry: &CommandRegistry,
|
||||
command_case_sensitivity: TopLevelCommandCaseSensitivity,
|
||||
) -> Option<String> {
|
||||
command_registry
|
||||
.signature_from_line(command, command_case_sensitivity)
|
||||
.and_then(|found_signature| {
|
||||
let arguments = found_signature.signature.arguments();
|
||||
arguments
|
||||
.get(idx)
|
||||
.map(|arg| arg.name().unwrap_or_default().to_string())
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,497 @@
|
||||
mod argument;
|
||||
mod command;
|
||||
mod flag;
|
||||
pub(crate) mod path;
|
||||
mod variable;
|
||||
|
||||
pub use argument::complete as argument_suggestions;
|
||||
pub use command::complete as command_suggestions;
|
||||
pub use flag::complete as flag_suggestions;
|
||||
pub use path::{EngineDirEntry, EngineFileType};
|
||||
pub use variable::suggestions as variable_suggestions;
|
||||
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(feature = "v2")] {
|
||||
mod v2;
|
||||
use v2::argument_name_at_index_for_command;
|
||||
} else {
|
||||
mod legacy;
|
||||
use legacy::argument_name_at_index_for_command;
|
||||
}
|
||||
}
|
||||
|
||||
use crate::{
|
||||
completer::{CompletionContext, TopLevelCommandCaseSensitivity},
|
||||
meta::{HasSpan, Span, Spanned, SpannedItem},
|
||||
parsers::{
|
||||
hir::{Command, Expression, ExternalCommand, FlagType, ShellCommand},
|
||||
ArgumentError, ClassifiedCommand, ParseError, ParseErrorReason, ParsedExpression,
|
||||
ParsedToken,
|
||||
},
|
||||
signatures::CommandRegistry,
|
||||
};
|
||||
|
||||
pub type CompletionLocation = Spanned<LocationType>;
|
||||
|
||||
#[derive(Debug, Clone, Eq, PartialEq)]
|
||||
pub enum LocationType {
|
||||
Command {
|
||||
/// Whether the command is in the registry.
|
||||
is_recognized: bool,
|
||||
parsed_token: ParsedToken,
|
||||
},
|
||||
Flag {
|
||||
/// The name of the command.
|
||||
command_name: Spanned<String>,
|
||||
/// The name of the flag. This will be `None` if no part of the flag was provided. For
|
||||
/// example, `git ` would provide a `Flag` location type with an empty `flag_name`.
|
||||
flag_name: Option<Spanned<String>>,
|
||||
},
|
||||
Argument {
|
||||
/// The name of the command.
|
||||
command_name: Spanned<String>,
|
||||
/// The name of the argument that has been typed so far. This will be `None` if no part of
|
||||
/// the argument was provided, such as `cd `.
|
||||
argument_name: Option<String>,
|
||||
parsed_token: ParsedToken,
|
||||
},
|
||||
Variable {
|
||||
parsed_token: ParsedToken,
|
||||
},
|
||||
}
|
||||
|
||||
impl LocationType {
|
||||
pub fn is_command(&self) -> bool {
|
||||
matches!(self, LocationType::Command { .. })
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Flatten<'s> {
|
||||
line: &'s str,
|
||||
context: &'s CommandRegistry,
|
||||
error: Option<&'s ParseError>,
|
||||
command: Spanned<String>,
|
||||
flag: Option<String>,
|
||||
}
|
||||
|
||||
impl<'s> Flatten<'s> {
|
||||
/// Converts a spanned `Expression` into a completion location for use in `WarpCompleter`.
|
||||
fn expression(&self, e: &Spanned<ParsedExpression>) -> Vec<CompletionLocation> {
|
||||
match e.item.expression() {
|
||||
Expression::Command => {
|
||||
vec![LocationType::Command {
|
||||
is_recognized: true,
|
||||
parsed_token: e.item.value().to_owned(),
|
||||
}
|
||||
.spanned(e.span)]
|
||||
}
|
||||
Expression::Literal => {
|
||||
vec![LocationType::Argument {
|
||||
command_name: self.command.clone(),
|
||||
argument_name: self.flag.clone(),
|
||||
parsed_token: e.item.value().clone(),
|
||||
}
|
||||
.spanned(e.span)]
|
||||
}
|
||||
Expression::ValidatableArgument(_) => {
|
||||
vec![LocationType::Argument {
|
||||
command_name: self.command.clone(),
|
||||
argument_name: self.flag.clone(),
|
||||
parsed_token: e.item.value().to_owned(),
|
||||
}
|
||||
.spanned(e.span)]
|
||||
}
|
||||
Expression::Unknown => Vec::new(),
|
||||
Expression::Variable => vec![LocationType::Variable {
|
||||
parsed_token: e.item.value().to_owned(),
|
||||
}
|
||||
.spanned(e.span)],
|
||||
}
|
||||
}
|
||||
|
||||
fn unclassified_command(
|
||||
&self,
|
||||
command: &ExternalCommand,
|
||||
command_case_sensitivity: TopLevelCommandCaseSensitivity,
|
||||
) -> Vec<CompletionLocation> {
|
||||
let capacity = 1
|
||||
+ command
|
||||
.args
|
||||
.flags
|
||||
.as_ref()
|
||||
.map_or(0, |flags| flags.flags.len())
|
||||
+ command
|
||||
.args
|
||||
.positionals
|
||||
.as_ref()
|
||||
.map_or(0, |positionals| positionals.len());
|
||||
let mut result = Vec::with_capacity(capacity);
|
||||
|
||||
match command.args.command_name.item.expression() {
|
||||
Expression::Command | Expression::Literal => result.push(
|
||||
LocationType::Command {
|
||||
is_recognized: false,
|
||||
parsed_token: command.name.clone(),
|
||||
}
|
||||
.spanned(command.name_span),
|
||||
),
|
||||
_ => (),
|
||||
}
|
||||
|
||||
if let Some(flags) = &command.args.flags {
|
||||
for flag in flags.iter() {
|
||||
match &flag.flag_type {
|
||||
FlagType::NoArgument => {
|
||||
result.push(
|
||||
LocationType::Flag {
|
||||
command_name: command
|
||||
.name
|
||||
.as_str()
|
||||
.to_owned()
|
||||
.spanned(command.name_span),
|
||||
flag_name: None,
|
||||
}
|
||||
.spanned(flag.name_span),
|
||||
);
|
||||
}
|
||||
|
||||
FlagType::Argument { value } => {
|
||||
result.push(
|
||||
LocationType::Flag {
|
||||
command_name: command
|
||||
.name
|
||||
.as_str()
|
||||
.to_owned()
|
||||
.spanned(command.name_span),
|
||||
flag_name: None,
|
||||
}
|
||||
.spanned(flag.name_span),
|
||||
);
|
||||
result.append(&mut self.with_flag(flag.name.clone()).expression(value));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(positionals) = &command.args.positionals {
|
||||
let positionals = positionals.iter();
|
||||
|
||||
result.extend(positionals.enumerate().flat_map(|(idx, positional)| {
|
||||
match positional.item.expression() {
|
||||
Expression::Unknown => {
|
||||
let unknown = positional.span.slice(self.line);
|
||||
let location = if unknown.starts_with('-') {
|
||||
LocationType::Flag {
|
||||
command_name: command
|
||||
.name
|
||||
.as_str()
|
||||
.to_owned()
|
||||
.spanned(command.name_span),
|
||||
flag_name: Some(unknown.to_string().spanned(positional.span)),
|
||||
}
|
||||
} else {
|
||||
LocationType::Argument {
|
||||
command_name: command
|
||||
.name
|
||||
.as_str()
|
||||
.to_owned()
|
||||
.spanned(command.name_span),
|
||||
argument_name: argument_name_at_index_for_command(
|
||||
command.name_span.slice(self.line),
|
||||
idx,
|
||||
self.context,
|
||||
command_case_sensitivity,
|
||||
),
|
||||
parsed_token: positional.item.value().clone(),
|
||||
}
|
||||
};
|
||||
|
||||
vec![location.spanned(positional.span)]
|
||||
}
|
||||
_ => self.expression(positional),
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
fn command(
|
||||
&self,
|
||||
command: &ShellCommand,
|
||||
command_case_sensitivity: TopLevelCommandCaseSensitivity,
|
||||
) -> Vec<CompletionLocation> {
|
||||
let capacity = 1
|
||||
+ command
|
||||
.args
|
||||
.flags
|
||||
.as_ref()
|
||||
.map_or(0, |flags| flags.flags.len())
|
||||
+ command
|
||||
.args
|
||||
.positionals
|
||||
.as_ref()
|
||||
.map_or(0, |positionals| positionals.len());
|
||||
let mut result = Vec::with_capacity(capacity);
|
||||
|
||||
let parsed_expression = &command.args.command_name.item;
|
||||
match parsed_expression.expression() {
|
||||
Expression::Command => {
|
||||
result.push(
|
||||
LocationType::Command {
|
||||
is_recognized: true,
|
||||
parsed_token: parsed_expression.value().to_owned(),
|
||||
}
|
||||
.spanned(command.name_span),
|
||||
);
|
||||
}
|
||||
Expression::Literal => {
|
||||
result.push(
|
||||
LocationType::Command {
|
||||
is_recognized: false,
|
||||
parsed_token: parsed_expression.value().to_owned(),
|
||||
}
|
||||
.spanned(command.name_span),
|
||||
);
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
|
||||
if let Some(positionals) = &command.args.positionals {
|
||||
let positionals = positionals.iter();
|
||||
|
||||
result.extend(positionals.enumerate().flat_map(|(idx, positional)| {
|
||||
match positional.item.expression() {
|
||||
Expression::Unknown => {
|
||||
let unknown = positional.span.slice(self.line);
|
||||
let location = if unknown.starts_with('-') {
|
||||
LocationType::Flag {
|
||||
command_name: command.name.clone().spanned(command.name_span),
|
||||
flag_name: Some(unknown.to_string().spanned(positional.span)),
|
||||
}
|
||||
} else {
|
||||
LocationType::Argument {
|
||||
command_name: command.name.clone().spanned(command.name_span),
|
||||
argument_name: argument_name_at_index_for_command(
|
||||
command.name_span.slice(self.line),
|
||||
idx,
|
||||
self.context,
|
||||
command_case_sensitivity,
|
||||
),
|
||||
parsed_token: positional.item.value().clone(),
|
||||
}
|
||||
};
|
||||
|
||||
vec![location.spanned(positional.span)]
|
||||
}
|
||||
_ => self.expression(positional),
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
if let Some(flags) = &command.args.flags {
|
||||
for flag in flags.iter() {
|
||||
result.push(
|
||||
LocationType::Flag {
|
||||
command_name: command.name.clone().spanned(command.name_span),
|
||||
flag_name: Some(
|
||||
flag.name_span
|
||||
.slice(self.line)
|
||||
.to_string()
|
||||
.spanned(flag.name_span),
|
||||
),
|
||||
}
|
||||
.spanned(flag.name_span),
|
||||
);
|
||||
if let FlagType::Argument { value } = &flag.flag_type {
|
||||
result.append(&mut self.with_flag(flag.name.clone()).expression(value));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Flags will not appear in [`crate::parsers::hir::CommandCallInfo::flags`] if they require
|
||||
// an argument but it's missing. In that case, it is a parse error. We still want known
|
||||
// flags which are missing an argument to be treated as a possible completion location. For
|
||||
// example, the cursor may be at the end of `npx --shell`, where `--shell` requires an
|
||||
// argument, and may actually want to complete the option `--shell-auto-fallback` instead.
|
||||
// So, check for this case in the parse error.
|
||||
if let Some(ParseError {
|
||||
reason:
|
||||
ParseErrorReason::ArgumentError {
|
||||
error: ArgumentError::MissingValueForName { name, .. },
|
||||
..
|
||||
},
|
||||
}) = self.error
|
||||
{
|
||||
result.push(
|
||||
LocationType::Flag {
|
||||
command_name: command.name.clone().spanned(command.name_span),
|
||||
flag_name: Some(name.clone()),
|
||||
}
|
||||
.spanned(name.span()),
|
||||
)
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Flattens the block into a Vec of completion locations
|
||||
pub fn completion_locations(
|
||||
&self,
|
||||
command: &Command,
|
||||
command_case_sensitivity: TopLevelCommandCaseSensitivity,
|
||||
) -> Vec<CompletionLocation> {
|
||||
match command {
|
||||
Command::Classified(cmd) => self.command(cmd, command_case_sensitivity),
|
||||
Command::Unclassified(cmd) => self.unclassified_command(cmd, command_case_sensitivity),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new(
|
||||
line: &'s str,
|
||||
context: &'s CommandRegistry,
|
||||
command: Spanned<String>,
|
||||
error: Option<&'s ParseError>,
|
||||
) -> Flatten<'s> {
|
||||
Flatten {
|
||||
line,
|
||||
context,
|
||||
error,
|
||||
command,
|
||||
flag: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_flag(&self, flag: String) -> Flatten<'s> {
|
||||
Flatten {
|
||||
line: self.line,
|
||||
context: self.context,
|
||||
error: self.error,
|
||||
command: self.command.clone(),
|
||||
flag: Some(flag),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Characters that precede a command name
|
||||
const BEFORE_COMMAND_CHARS: &[char] = &['|', '(', ';'];
|
||||
|
||||
/// Determines the completion location for a given block at the given cursor position
|
||||
pub fn completion_location(
|
||||
ctx: &dyn CompletionContext,
|
||||
line: &str,
|
||||
classified_command: Option<&ClassifiedCommand>,
|
||||
) -> Vec<CompletionLocation> {
|
||||
let (command, error) = match classified_command {
|
||||
Some(command_with_error) => (&command_with_error.command, &command_with_error.error),
|
||||
// If there's no command--treat the completion location as a single foreign command to
|
||||
// surface top level commands.
|
||||
None => {
|
||||
return vec![LocationType::Command {
|
||||
is_recognized: false,
|
||||
parsed_token: ParsedToken::empty(),
|
||||
}
|
||||
.spanned(Span::default())]
|
||||
}
|
||||
};
|
||||
|
||||
let completion_engine = Flatten::new(
|
||||
line,
|
||||
ctx.command_registry(),
|
||||
command.command_name_span().map(ToOwned::to_owned),
|
||||
error.as_ref(),
|
||||
);
|
||||
let locations = completion_engine.completion_locations(command, ctx.command_case_sensitivity());
|
||||
|
||||
if locations.is_empty() {
|
||||
vec![LocationType::Command {
|
||||
is_recognized: false,
|
||||
parsed_token: ParsedToken::empty(),
|
||||
}
|
||||
.spanned(Span::default())]
|
||||
} else {
|
||||
let mut command = None;
|
||||
let mut prev = None;
|
||||
for loc in locations {
|
||||
// We don't use span.contains because we want to include the end. This handles the case
|
||||
// where the cursor is just after the text (i.e., no space between cursor and text)
|
||||
if loc.span.start() <= line.len() && line.len() <= loc.span.end() {
|
||||
// The parser sees the "-" in `cmd -` as an argument, but the user is likely
|
||||
// expecting a flag.
|
||||
return match loc.item {
|
||||
LocationType::Argument {
|
||||
command_name: ref cmd,
|
||||
..
|
||||
} => {
|
||||
let cmd = cmd.clone();
|
||||
if loc.span.slice(line) == "-" {
|
||||
let span = loc.span;
|
||||
return vec![
|
||||
loc,
|
||||
LocationType::Flag {
|
||||
command_name: cmd,
|
||||
flag_name: Some("-".to_owned().spanned(span)),
|
||||
}
|
||||
.spanned(span),
|
||||
];
|
||||
}
|
||||
// This ensures that flags are not included if the user has already typed a
|
||||
// non "-" such as "git c<tab>"
|
||||
vec![loc]
|
||||
}
|
||||
_ => vec![loc],
|
||||
};
|
||||
} else if line.len() < loc.span.start() {
|
||||
break;
|
||||
}
|
||||
|
||||
if loc.item.is_command() {
|
||||
command = Some(String::from(loc.span.slice(line)).spanned(loc.span));
|
||||
}
|
||||
|
||||
prev = Some(loc);
|
||||
}
|
||||
|
||||
if let Some(prev) = prev {
|
||||
// Cursor is between locations (or at the end). Look at the line to see if the cursor
|
||||
// is after some character that would imply we're in the command position.
|
||||
let start = prev.span.end();
|
||||
if line[start..].contains(BEFORE_COMMAND_CHARS) {
|
||||
vec![LocationType::Command {
|
||||
is_recognized: true,
|
||||
parsed_token: ParsedToken::empty(),
|
||||
}
|
||||
.spanned(Span::new(line.len(), line.len()))]
|
||||
} else if let Some(command) = command {
|
||||
let arg_location = LocationType::Argument {
|
||||
command_name: command.clone(),
|
||||
argument_name: None,
|
||||
parsed_token: ParsedToken::empty(),
|
||||
}
|
||||
.spanned(Span::new(line.len(), line.len()));
|
||||
|
||||
let flag_location = LocationType::Flag {
|
||||
command_name: command,
|
||||
flag_name: None,
|
||||
}
|
||||
.spanned(Span::new(line.len(), line.len()));
|
||||
|
||||
vec![arg_location, flag_location]
|
||||
} else {
|
||||
vec![]
|
||||
}
|
||||
} else {
|
||||
// Cursor is before any possible completion location, so must be a command
|
||||
vec![LocationType::Command {
|
||||
is_recognized: true,
|
||||
parsed_token: ParsedToken::empty(),
|
||||
}
|
||||
.spanned(Span::new(line.len(), line.len()))]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "v2"))]
|
||||
#[cfg(test)]
|
||||
#[path = "test.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,311 @@
|
||||
use std::fmt::{Display, Formatter};
|
||||
use std::fs::DirEntry;
|
||||
|
||||
use itertools::{iproduct, Itertools};
|
||||
use lazy_static::lazy_static;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use typed_path::{TypedPath, TypedPathBuf};
|
||||
use warp_command_signatures::{IconType, PathSuggestionType};
|
||||
use galaxy_util::path::HOME_DIR_ENV_VAR_PREFIX;
|
||||
|
||||
use crate::completer::suggest::Priority;
|
||||
use crate::completer::{
|
||||
context::PathCompletionContext,
|
||||
matchers::MatchStrategy,
|
||||
suggest::{MatchedSuggestion, Suggestion, SuggestionType},
|
||||
};
|
||||
use crate::parsers::ParsedToken;
|
||||
|
||||
/// TODO(CORE-3074): This only applies to Unix.
|
||||
const ROOT_DIR_STR: &str = "/";
|
||||
|
||||
lazy_static! {
|
||||
pub static ref CURR_DIRECTORY_ENTRY: EngineDirEntry = EngineDirEntry {
|
||||
file_name: ".".to_owned(),
|
||||
file_type: EngineFileType::Directory,
|
||||
};
|
||||
pub static ref PARENT_DIRECTORY_ENTRY: EngineDirEntry = EngineDirEntry {
|
||||
file_name: "..".to_owned(),
|
||||
file_type: EngineFileType::Directory,
|
||||
};
|
||||
}
|
||||
|
||||
/// A `DirEntry` for the completions engine that abstracts whether the contents
|
||||
/// come from a remote or local filesystem.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Hash)]
|
||||
pub struct EngineDirEntry {
|
||||
pub file_name: String,
|
||||
pub file_type: EngineFileType,
|
||||
}
|
||||
|
||||
impl EngineDirEntry {
|
||||
pub fn is_dir(&self) -> bool {
|
||||
self.file_type == EngineFileType::Directory
|
||||
}
|
||||
|
||||
pub fn file_name(&self) -> &str {
|
||||
self.file_name.as_str()
|
||||
}
|
||||
|
||||
pub fn is_hidden(&self) -> bool {
|
||||
self.file_name.starts_with('.')
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<DirEntry> for EngineDirEntry {
|
||||
type Error = std::io::Error;
|
||||
|
||||
fn try_from(value: DirEntry) -> Result<Self, Self::Error> {
|
||||
let file_type = value.file_type()?;
|
||||
let is_dir = if file_type.is_dir() {
|
||||
true
|
||||
} else if file_type.is_symlink() {
|
||||
// If the file is a symlink, follow the symlink and check if the target is a directory.
|
||||
value
|
||||
.path()
|
||||
.metadata()
|
||||
.map(|metadata| metadata.is_dir())
|
||||
.unwrap_or(false)
|
||||
} else {
|
||||
false
|
||||
};
|
||||
let file_type = if is_dir {
|
||||
EngineFileType::Directory
|
||||
} else {
|
||||
EngineFileType::File
|
||||
};
|
||||
Ok(Self {
|
||||
file_name: value.file_name().to_string_lossy().to_string(),
|
||||
file_type,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize, Hash)]
|
||||
pub enum EngineFileType {
|
||||
Directory,
|
||||
File,
|
||||
}
|
||||
|
||||
impl Display for EngineFileType {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"{}",
|
||||
match self {
|
||||
EngineFileType::Directory => "Directory",
|
||||
EngineFileType::File => "File",
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<EngineFileType> for PathSuggestionType {
|
||||
fn from(path_type: EngineFileType) -> Self {
|
||||
match path_type {
|
||||
EngineFileType::Directory => Self::Folder,
|
||||
EngineFileType::File => Self::File,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the sorted directories relative to the provided path and filter.
|
||||
///
|
||||
/// Note we are returning a Vector instead of iterator here because Rust currently doesn't support
|
||||
/// returning opaque types (impl) in traits. This should have minimum impact on the memory allocation
|
||||
/// since we are already calling `sort_by` before collecting which allocates memory.
|
||||
pub(crate) async fn sorted_directories_relative_to(
|
||||
path: &ParsedToken,
|
||||
matcher: MatchStrategy,
|
||||
ctx: &dyn PathCompletionContext,
|
||||
) -> Vec<MatchedSuggestion> {
|
||||
list_directory_contents(path, matcher, ctx)
|
||||
.await
|
||||
.into_iter()
|
||||
.filter(|path_suggestion| {
|
||||
path_suggestion
|
||||
.suggestion
|
||||
.file_type
|
||||
.is_some_and(|file_type| file_type == EngineFileType::Directory)
|
||||
})
|
||||
.sorted_by(|suggestion_a, suggestion_b| {
|
||||
suggestion_a
|
||||
.suggestion
|
||||
.cmp_by_display(&suggestion_b.suggestion)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub async fn sorted_paths_relative_to(
|
||||
path: &ParsedToken,
|
||||
matcher: MatchStrategy,
|
||||
ctx: &dyn PathCompletionContext,
|
||||
) -> Vec<MatchedSuggestion> {
|
||||
list_directory_contents(path, matcher, ctx)
|
||||
.await
|
||||
.into_iter()
|
||||
.sorted_by(|suggestion_a, suggestion_b| {
|
||||
suggestion_a
|
||||
.suggestion
|
||||
.cmp_by_display(&suggestion_b.suggestion)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Lists all directory contents within the directory identified by the parent directory of
|
||||
/// `relative_to`.
|
||||
/// If `relative_to` is `foo/bar/`, directory contents beanth `bar/` will be returned.
|
||||
/// If `relative_to` is `foo/bar/a`, directory contents relative to `/bar` are returned, while
|
||||
/// ensuring they match the trailing `a`.
|
||||
/// `relative_to` can contain backslash escaped tildes so we can distinguish between tildes that
|
||||
/// should be expanded into the home directory and a literal tilde.
|
||||
/// NOTE: The resulting suggestion replacements are shell-escaped; display values are unescaped.
|
||||
async fn list_directory_contents(
|
||||
relative_to: &ParsedToken,
|
||||
matcher: MatchStrategy,
|
||||
ctx: &dyn PathCompletionContext,
|
||||
) -> Vec<MatchedSuggestion> {
|
||||
let home_dir = ctx.home_directory();
|
||||
|
||||
let path_separators = ctx.path_separators();
|
||||
let split_path = SplitPath::new(
|
||||
ctx.pwd(),
|
||||
relative_to.as_str(),
|
||||
home_dir,
|
||||
path_separators.all,
|
||||
);
|
||||
|
||||
let dir_entries = ctx
|
||||
.list_directory_entries(split_path.directory_absolute_path.clone())
|
||||
.await;
|
||||
|
||||
let root_dir_entry =
|
||||
(split_path.directory_absolute_path.to_str() == Some(ROOT_DIR_STR)).then(|| {
|
||||
EngineDirEntry {
|
||||
file_name: ROOT_DIR_STR.to_owned(),
|
||||
file_type: EngineFileType::Directory,
|
||||
}
|
||||
});
|
||||
|
||||
dir_entries
|
||||
.iter()
|
||||
.chain(root_dir_entry.iter())
|
||||
.chain([&*CURR_DIRECTORY_ENTRY, &*PARENT_DIRECTORY_ENTRY])
|
||||
.filter_map(move |entry| {
|
||||
let mut file_name = entry.file_name().to_string();
|
||||
|
||||
let match_type = matcher.get_match_type(&split_path.file_name, file_name.as_str())?;
|
||||
|
||||
let path = if entry.file_name() == ROOT_DIR_STR {
|
||||
ROOT_DIR_STR.to_owned()
|
||||
} else {
|
||||
if entry.is_dir() {
|
||||
file_name.push(path_separators.main);
|
||||
}
|
||||
// We use `shell_escape()` instead of `escape()` on the relative path name to allow
|
||||
// home directory expansion if needed.
|
||||
format!(
|
||||
"{}{}",
|
||||
if split_path.directory_relative_path_name.is_empty() {
|
||||
"".to_owned()
|
||||
} else {
|
||||
// `directory_relative_path_name` may have escaped tildes which we use to
|
||||
// distinguish between a tilde representing the home directory and a literal
|
||||
// tilde. `shell_escape()` will doubly escape an escaped tilde which is
|
||||
// incorrect so we correct that behavior here.
|
||||
ctx.shell_family()
|
||||
.shell_escape(split_path.directory_relative_path_name.as_str())
|
||||
.replace(r"\\\~", r"\~")
|
||||
},
|
||||
// Home directory expansion is never needed on file names, so we use the
|
||||
// standard `escape()`.
|
||||
ctx.shell_family().escape(file_name.as_str())
|
||||
)
|
||||
};
|
||||
|
||||
(!entry.is_hidden() || split_path.file_name.starts_with('.')).then(|| {
|
||||
let mut suggestion = Suggestion::new(
|
||||
file_name.as_str(),
|
||||
path,
|
||||
Some(entry.file_type.to_string()),
|
||||
SuggestionType::Argument,
|
||||
Priority::default(),
|
||||
);
|
||||
suggestion.file_type = Some(entry.file_type);
|
||||
suggestion.override_icon = Some(match entry.file_type {
|
||||
EngineFileType::File => IconType::File,
|
||||
EngineFileType::Directory => IconType::Folder,
|
||||
});
|
||||
MatchedSuggestion {
|
||||
suggestion,
|
||||
match_type,
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect_vec()
|
||||
}
|
||||
|
||||
/// A path split into the parent path (the entire piece before the last separator) and the
|
||||
/// file_name (the piece after the last separator).
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
struct SplitPath {
|
||||
/// The absolute path to the directory containing the file named `file_name`.
|
||||
directory_absolute_path: TypedPathBuf,
|
||||
|
||||
/// The path to the directory containing the file named `file_name`, relative to the current
|
||||
/// working directory. This is may contain unexpanded `~` or `$HOME`.
|
||||
directory_relative_path_name: String,
|
||||
|
||||
/// The name of the `file`.
|
||||
file_name: String,
|
||||
}
|
||||
|
||||
impl SplitPath {
|
||||
/// Returns a `SplitPath` based on the given path values.
|
||||
///
|
||||
/// `current_directory` is the directory to which `relative_path` is relative.
|
||||
/// `relative_path` may contain '~' or '$HOME'. If `relative_path` begins with one of those
|
||||
/// strings, we expand that part of the path to the given `home_directory` value, if it is
|
||||
/// `Some()`. Note that `relative_path` comes directly from a user-specified path token. This
|
||||
/// may contain escaped tildes (for example if the user is completing on a path that contains
|
||||
/// literal tildes), which need to be unescaped before using the path to generate path
|
||||
/// suggestions.
|
||||
fn new(
|
||||
current_directory: TypedPath,
|
||||
relative_path: &str,
|
||||
home_directory: Option<&str>,
|
||||
path_separators: &[char],
|
||||
) -> Self {
|
||||
let (directory_relative_path_name, file_name) = match relative_path.rfind(path_separators) {
|
||||
Some(pos) => relative_path.split_at(pos + 1),
|
||||
None => ("", relative_path),
|
||||
};
|
||||
|
||||
let directory_absolute_path = if directory_relative_path_name.is_empty() {
|
||||
current_directory.to_path_buf()
|
||||
} else if let Some(rest) = iproduct!([HOME_DIR_ENV_VAR_PREFIX, "~"], path_separators)
|
||||
.find_map(|(prefix, sep)| {
|
||||
directory_relative_path_name.strip_prefix(&format!("{prefix}{sep}"))
|
||||
})
|
||||
{
|
||||
let mut home_directory = TypedPathBuf::from(home_directory.unwrap_or_default());
|
||||
home_directory.push(rest.replace(r"\~", "~"));
|
||||
home_directory
|
||||
} else {
|
||||
current_directory.join(directory_relative_path_name.replace(r"\~", "~"))
|
||||
};
|
||||
|
||||
// Unescape escaped tildes in the filename.
|
||||
let file_name = file_name.replace(r"\~", "~");
|
||||
|
||||
SplitPath {
|
||||
directory_absolute_path,
|
||||
directory_relative_path_name: directory_relative_path_name.to_owned(),
|
||||
file_name,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "path_test.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,474 @@
|
||||
use warp_command_signatures::IconType;
|
||||
|
||||
use crate::completer::testing::MockPathCompletionContext;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[cfg(windows)]
|
||||
mod windows_constants {
|
||||
pub(super) const TEST_HOME_DIR: &str = r"C:\Users\test";
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
use windows_constants::*;
|
||||
|
||||
#[cfg(unix)]
|
||||
mod unix_constants {
|
||||
pub(super) const TEST_HOME_DIR: &str = "/users/test";
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
use unix_constants::*;
|
||||
|
||||
#[test]
|
||||
fn test_split_path() {
|
||||
let path = TypedPathBuf::from_unix("/Users/warpuser");
|
||||
let split_path = SplitPath::new(
|
||||
path.to_path(),
|
||||
"~/Warp.app",
|
||||
Some("/Users/warpuser"),
|
||||
&['/'],
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
split_path,
|
||||
SplitPath {
|
||||
directory_absolute_path: path.clone(),
|
||||
directory_relative_path_name: "~/".to_owned(),
|
||||
file_name: "Warp.app".to_owned()
|
||||
}
|
||||
);
|
||||
|
||||
let split_path = SplitPath::new(
|
||||
path.to_path(),
|
||||
"Warp.app/Contents",
|
||||
Some("/Users/warpuser"),
|
||||
&['/'],
|
||||
);
|
||||
assert_eq!(
|
||||
split_path,
|
||||
SplitPath {
|
||||
directory_absolute_path: TypedPathBuf::from("/Users/warpuser/Warp.app/"),
|
||||
directory_relative_path_name: "Warp.app/".to_owned(),
|
||||
file_name: "Contents".to_owned()
|
||||
}
|
||||
);
|
||||
|
||||
let split_path = SplitPath::new(
|
||||
path.to_path(),
|
||||
"Warp.app/macOS/bin/warp.o",
|
||||
Some("/Users/warpuser"),
|
||||
&['/'],
|
||||
);
|
||||
assert_eq!(
|
||||
split_path,
|
||||
SplitPath {
|
||||
directory_absolute_path: TypedPathBuf::from("/Users/warpuser/Warp.app/macOS/bin/"),
|
||||
directory_relative_path_name: "Warp.app/macOS/bin/".to_owned(),
|
||||
file_name: "warp.o".to_owned()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
fn file_entry(file_name: &str) -> EngineDirEntry {
|
||||
EngineDirEntry {
|
||||
file_name: file_name.to_owned(),
|
||||
file_type: EngineFileType::File,
|
||||
}
|
||||
}
|
||||
|
||||
fn dir_entry(file_name: &str) -> EngineDirEntry {
|
||||
EngineDirEntry {
|
||||
file_name: file_name.to_owned(),
|
||||
file_type: EngineFileType::Directory,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(
|
||||
windows,
|
||||
ignore = "CORE-3696: path sorting comparison function needs separators"
|
||||
)]
|
||||
#[test]
|
||||
pub fn test_sorted_paths_relative_to() {
|
||||
let ctx = MockPathCompletionContext::default().with_entries_in_pwd([
|
||||
file_entry("Cargo.toml"),
|
||||
dir_entry("src"),
|
||||
dir_entry("target"),
|
||||
dir_entry(".hidden"),
|
||||
]);
|
||||
|
||||
assert_eq!(
|
||||
galaxyui::r#async::block_on(sorted_paths_relative_to(
|
||||
&ParsedToken::empty(),
|
||||
MatchStrategy::CaseInsensitive,
|
||||
&ctx
|
||||
))
|
||||
.into_iter()
|
||||
.map(|matched_suggestion| matched_suggestion.suggestion)
|
||||
.collect_vec(),
|
||||
vec![
|
||||
Suggestion::with_same_display_and_replacement(
|
||||
"Cargo.toml",
|
||||
Some("File".into()),
|
||||
SuggestionType::Argument,
|
||||
Priority::default(),
|
||||
)
|
||||
.with_icon_override(IconType::File)
|
||||
.with_file_type(EngineFileType::File),
|
||||
Suggestion::with_same_display_and_replacement(
|
||||
"src/",
|
||||
Some("Directory".into()),
|
||||
SuggestionType::Argument,
|
||||
Priority::default(),
|
||||
)
|
||||
.with_icon_override(IconType::Folder)
|
||||
.with_file_type(EngineFileType::Directory),
|
||||
Suggestion::with_same_display_and_replacement(
|
||||
"target/",
|
||||
Some("Directory".into()),
|
||||
SuggestionType::Argument,
|
||||
Priority::default(),
|
||||
)
|
||||
.with_icon_override(IconType::Folder)
|
||||
.with_file_type(EngineFileType::Directory),
|
||||
]
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
galaxyui::r#async::block_on(sorted_paths_relative_to(
|
||||
&ParsedToken::new("sr"),
|
||||
MatchStrategy::CaseInsensitive,
|
||||
&ctx
|
||||
))
|
||||
.into_iter()
|
||||
.map(|matched_suggestion| matched_suggestion.suggestion)
|
||||
.collect_vec(),
|
||||
vec![Suggestion::with_same_display_and_replacement(
|
||||
"src/",
|
||||
Some("Directory".into()),
|
||||
SuggestionType::Argument,
|
||||
Priority::default(),
|
||||
)
|
||||
.with_icon_override(IconType::Folder)
|
||||
.with_file_type(EngineFileType::Directory)]
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
galaxyui::r#async::block_on(sorted_paths_relative_to(
|
||||
&ParsedToken::new("."),
|
||||
MatchStrategy::CaseInsensitive,
|
||||
&ctx
|
||||
))
|
||||
.into_iter()
|
||||
.map(|matched_suggestion| matched_suggestion.suggestion)
|
||||
.collect_vec(),
|
||||
vec![
|
||||
Suggestion::with_same_display_and_replacement(
|
||||
"./",
|
||||
Some("Directory".into()),
|
||||
SuggestionType::Argument,
|
||||
Priority::default(),
|
||||
)
|
||||
.with_icon_override(IconType::Folder)
|
||||
.with_file_type(EngineFileType::Directory),
|
||||
Suggestion::with_same_display_and_replacement(
|
||||
"../",
|
||||
Some("Directory".into()),
|
||||
SuggestionType::Argument,
|
||||
Priority::default(),
|
||||
)
|
||||
.with_icon_override(IconType::Folder)
|
||||
.with_file_type(EngineFileType::Directory),
|
||||
Suggestion::with_same_display_and_replacement(
|
||||
".hidden/",
|
||||
Some("Directory".into()),
|
||||
SuggestionType::Argument,
|
||||
Priority::default(),
|
||||
)
|
||||
.with_icon_override(IconType::Folder)
|
||||
.with_file_type(EngineFileType::Directory),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
pub fn test_sorted_directories_relative_to() {
|
||||
let ctx = MockPathCompletionContext::default().with_entries_in_pwd([
|
||||
file_entry("Cargo.toml"),
|
||||
dir_entry("src"),
|
||||
dir_entry("target"),
|
||||
dir_entry(".hidden"),
|
||||
]);
|
||||
|
||||
assert_eq!(
|
||||
galaxyui::r#async::block_on(sorted_directories_relative_to(
|
||||
&ParsedToken::empty(),
|
||||
MatchStrategy::CaseInsensitive,
|
||||
&ctx
|
||||
))
|
||||
.into_iter()
|
||||
.map(|matched_suggestion| matched_suggestion.suggestion)
|
||||
.collect_vec(),
|
||||
vec![
|
||||
Suggestion::with_same_display_and_replacement(
|
||||
"src/",
|
||||
Some("Directory".into()),
|
||||
SuggestionType::Argument,
|
||||
Priority::default(),
|
||||
)
|
||||
.with_icon_override(IconType::Folder)
|
||||
.with_file_type(EngineFileType::Directory),
|
||||
Suggestion::with_same_display_and_replacement(
|
||||
"target/",
|
||||
Some("Directory".into()),
|
||||
SuggestionType::Argument,
|
||||
Priority::default(),
|
||||
)
|
||||
.with_icon_override(IconType::Folder)
|
||||
.with_file_type(EngineFileType::Directory),
|
||||
]
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
galaxyui::r#async::block_on(sorted_directories_relative_to(
|
||||
&ParsedToken::new("s"),
|
||||
MatchStrategy::CaseInsensitive,
|
||||
&ctx
|
||||
))
|
||||
.into_iter()
|
||||
.map(|matched_suggestion| matched_suggestion.suggestion)
|
||||
.collect_vec(),
|
||||
vec![Suggestion::with_same_display_and_replacement(
|
||||
"src/",
|
||||
Some("Directory".into()),
|
||||
SuggestionType::Argument,
|
||||
Priority::default(),
|
||||
)
|
||||
.with_icon_override(IconType::Folder)
|
||||
.with_file_type(EngineFileType::Directory)]
|
||||
);
|
||||
}
|
||||
|
||||
/// Verify that path suggestions are sorted case-insensitively so that uppercase entries
|
||||
/// don't always appear before lowercase ones.
|
||||
#[cfg_attr(
|
||||
windows,
|
||||
ignore = "CORE-3696: path sorting comparison function needs separators"
|
||||
)]
|
||||
#[test]
|
||||
pub fn test_sorted_paths_case_insensitive_ordering() {
|
||||
let ctx = MockPathCompletionContext::default().with_entries_in_pwd([
|
||||
file_entry("Zebra.txt"),
|
||||
file_entry("apple.txt"),
|
||||
dir_entry("Banana"),
|
||||
file_entry("cherry.txt"),
|
||||
]);
|
||||
|
||||
let suggestions: Vec<String> = galaxyui::r#async::block_on(sorted_paths_relative_to(
|
||||
&ParsedToken::empty(),
|
||||
MatchStrategy::CaseInsensitive,
|
||||
&ctx,
|
||||
))
|
||||
.into_iter()
|
||||
.map(|matched_suggestion| matched_suggestion.suggestion.display.to_string())
|
||||
.collect();
|
||||
|
||||
// Expected case-insensitive order: apple, Banana, cherry, Zebra
|
||||
assert_eq!(
|
||||
suggestions,
|
||||
vec!["apple.txt", "Banana/", "cherry.txt", "Zebra.txt"]
|
||||
);
|
||||
}
|
||||
|
||||
fn mock_path_completion_ctx_special_characters() -> MockPathCompletionContext {
|
||||
MockPathCompletionContext::default()
|
||||
.with_home_directory(TEST_HOME_DIR.to_owned())
|
||||
.with_entries_in_pwd([dir_entry("!nice ~"), dir_entry("~"), dir_entry("~foo")])
|
||||
}
|
||||
|
||||
/// Check that special characters are properly escaped in the Suggestion.
|
||||
#[test]
|
||||
pub fn test_path_completions_with_special_characters_relative_to_cwd() {
|
||||
let ctx = mock_path_completion_ctx_special_characters();
|
||||
|
||||
assert_eq!(
|
||||
galaxyui::r#async::block_on(sorted_directories_relative_to(
|
||||
&ParsedToken::empty(),
|
||||
MatchStrategy::CaseInsensitive,
|
||||
&ctx
|
||||
))
|
||||
.into_iter()
|
||||
.map(|matched_suggestion| matched_suggestion.suggestion)
|
||||
.collect_vec(),
|
||||
vec![
|
||||
Suggestion::new(
|
||||
"!nice ~/",
|
||||
r"\!nice\ \~/",
|
||||
Some("Directory".into()),
|
||||
SuggestionType::Argument,
|
||||
Priority::default(),
|
||||
)
|
||||
.with_icon_override(IconType::Folder)
|
||||
.with_file_type(EngineFileType::Directory),
|
||||
Suggestion::new(
|
||||
"~/",
|
||||
r"\~/",
|
||||
Some("Directory".into()),
|
||||
SuggestionType::Argument,
|
||||
Priority::default(),
|
||||
)
|
||||
.with_icon_override(IconType::Folder)
|
||||
.with_file_type(EngineFileType::Directory),
|
||||
Suggestion::new(
|
||||
"~foo/",
|
||||
r"\~foo/",
|
||||
Some("Directory".into()),
|
||||
SuggestionType::Argument,
|
||||
Priority::default(),
|
||||
)
|
||||
.with_icon_override(IconType::Folder)
|
||||
.with_file_type(EngineFileType::Directory),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// Check that we can match on special characters at the beginning of the file name.
|
||||
#[test]
|
||||
pub fn test_path_completions_with_special_character_case_insensitive() {
|
||||
let ctx = mock_path_completion_ctx_special_characters();
|
||||
assert_eq!(
|
||||
galaxyui::r#async::block_on(sorted_directories_relative_to(
|
||||
&ParsedToken::new("~"),
|
||||
MatchStrategy::CaseInsensitive,
|
||||
&ctx
|
||||
))
|
||||
.into_iter()
|
||||
.map(|matched_suggestion| matched_suggestion.suggestion)
|
||||
.collect_vec(),
|
||||
vec![
|
||||
Suggestion::new(
|
||||
"~/",
|
||||
r"\~/",
|
||||
Some("Directory".into()),
|
||||
SuggestionType::Argument,
|
||||
Priority::default(),
|
||||
)
|
||||
.with_icon_override(IconType::Folder)
|
||||
.with_file_type(EngineFileType::Directory),
|
||||
Suggestion::new(
|
||||
"~foo/",
|
||||
r"\~foo/",
|
||||
Some("Directory".into()),
|
||||
SuggestionType::Argument,
|
||||
Priority::default(),
|
||||
)
|
||||
.with_icon_override(IconType::Folder)
|
||||
.with_file_type(EngineFileType::Directory),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// Check that we can match on special characters regardless of their position in the file name.
|
||||
#[test]
|
||||
pub fn test_path_completions_with_special_characters_fuzzy() {
|
||||
let ctx = mock_path_completion_ctx_special_characters();
|
||||
|
||||
assert_eq!(
|
||||
galaxyui::r#async::block_on(sorted_directories_relative_to(
|
||||
&ParsedToken::new("~"),
|
||||
MatchStrategy::Fuzzy,
|
||||
&ctx
|
||||
))
|
||||
.into_iter()
|
||||
.map(|matched_suggestion| matched_suggestion.suggestion)
|
||||
.collect_vec(),
|
||||
vec![
|
||||
Suggestion::new(
|
||||
"!nice ~/",
|
||||
r"\!nice\ \~/",
|
||||
Some("Directory".into()),
|
||||
SuggestionType::Argument,
|
||||
Priority::default(),
|
||||
)
|
||||
.with_icon_override(IconType::Folder)
|
||||
.with_file_type(EngineFileType::Directory),
|
||||
Suggestion::new(
|
||||
"~/",
|
||||
r"\~/",
|
||||
Some("Directory".into()),
|
||||
SuggestionType::Argument,
|
||||
Priority::default(),
|
||||
)
|
||||
.with_icon_override(IconType::Folder)
|
||||
.with_file_type(EngineFileType::Directory),
|
||||
Suggestion::new(
|
||||
"~foo/",
|
||||
r"\~foo/",
|
||||
Some("Directory".into()),
|
||||
SuggestionType::Argument,
|
||||
Priority::default(),
|
||||
)
|
||||
.with_icon_override(IconType::Folder)
|
||||
.with_file_type(EngineFileType::Directory),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
fn mock_path_completion_ctx_special_characters_home_dir() -> MockPathCompletionContext {
|
||||
MockPathCompletionContext::default()
|
||||
.with_home_directory(TEST_HOME_DIR.to_owned())
|
||||
.with_entries_in_pwd([dir_entry("~")])
|
||||
.with_entries(TEST_HOME_DIR.into(), [dir_entry(r"~ testdir")])
|
||||
}
|
||||
|
||||
/// Check that tilde expansion works with path completion and special characters in Suggestions.
|
||||
#[test]
|
||||
pub fn test_path_completions_tilde_expansion() {
|
||||
let ctx = mock_path_completion_ctx_special_characters_home_dir();
|
||||
|
||||
assert_eq!(
|
||||
galaxyui::r#async::block_on(sorted_directories_relative_to(
|
||||
&ParsedToken::new("~/"),
|
||||
MatchStrategy::Fuzzy,
|
||||
&ctx
|
||||
))
|
||||
.into_iter()
|
||||
.map(|matched_suggestion| matched_suggestion.suggestion)
|
||||
.collect_vec(),
|
||||
vec![Suggestion::new(
|
||||
"~ testdir/",
|
||||
r"~/\~\ testdir/",
|
||||
Some("Directory".into()),
|
||||
SuggestionType::Argument,
|
||||
Priority::default(),
|
||||
)
|
||||
.with_icon_override(IconType::Folder)
|
||||
.with_file_type(EngineFileType::Directory),]
|
||||
);
|
||||
}
|
||||
|
||||
/// Check that $HOME home directory expansion works with special characters in the suggestions.
|
||||
#[test]
|
||||
pub fn test_path_completions_home_env_var_special_characters() {
|
||||
let ctx = mock_path_completion_ctx_special_characters_home_dir();
|
||||
|
||||
assert_eq!(
|
||||
galaxyui::r#async::block_on(sorted_directories_relative_to(
|
||||
&ParsedToken::new("$HOME/"),
|
||||
MatchStrategy::Fuzzy,
|
||||
&ctx
|
||||
))
|
||||
.into_iter()
|
||||
.map(|matched_suggestion| matched_suggestion.suggestion)
|
||||
.collect_vec(),
|
||||
vec![Suggestion::new(
|
||||
"~ testdir/",
|
||||
r"$HOME/\~\ testdir/",
|
||||
Some("Directory".into()),
|
||||
SuggestionType::Argument,
|
||||
Priority::default(),
|
||||
)
|
||||
.with_icon_override(IconType::Folder)
|
||||
.with_file_type(EngineFileType::Directory),]
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
use itertools::Itertools;
|
||||
use galaxy_util::path::EscapeChar;
|
||||
|
||||
use super::LocationType;
|
||||
use crate::completer::testing::FakeCompletionContext;
|
||||
use crate::completer::CompletionContext;
|
||||
use crate::meta::{Span, SpannedItem};
|
||||
use crate::parsers::simple::command_at_cursor_position;
|
||||
use crate::parsers::ParsedToken;
|
||||
use crate::parsers::{classify_command, simple::parse_for_completions};
|
||||
use crate::signatures::testing::{create_test_command_registry, test_signature};
|
||||
use crate::signatures::CommandRegistry;
|
||||
use string_offset::ByteOffset;
|
||||
|
||||
fn location(line: &str, registry: CommandRegistry, pos: usize) -> Vec<LocationType> {
|
||||
let ctx = FakeCompletionContext::new(registry);
|
||||
let line = &line[..pos];
|
||||
|
||||
let command_to_complete = parse_for_completions(line, EscapeChar::Backslash, false)
|
||||
.expect("test command should be able to parse");
|
||||
let mut tokens = command_to_complete
|
||||
.parts
|
||||
.iter()
|
||||
.map(|s| s.as_str())
|
||||
.collect_vec();
|
||||
|
||||
let classified_command = classify_command(
|
||||
command_to_complete.clone(),
|
||||
&mut tokens,
|
||||
ctx.command_registry(),
|
||||
ctx.command_case_sensitivity(),
|
||||
);
|
||||
|
||||
crate::completer::engine::completion_location(&ctx, line, classified_command.as_ref())
|
||||
.into_iter()
|
||||
.map(|v| v.item)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_command_at_cursor_parse() {
|
||||
let line = r"git status $(git stash) && git checkout main";
|
||||
|
||||
let parse_first_command =
|
||||
command_at_cursor_position(line, EscapeChar::Backslash, ByteOffset::from(4));
|
||||
assert!(parse_first_command.is_some());
|
||||
assert_eq!(
|
||||
Span::from_list(&parse_first_command.unwrap().parts),
|
||||
Span::new(0, 23)
|
||||
);
|
||||
|
||||
let parse_second_command =
|
||||
command_at_cursor_position(line, EscapeChar::Backslash, ByteOffset::from(17));
|
||||
assert!(parse_second_command.is_some());
|
||||
assert_eq!(
|
||||
Span::from_list(&parse_second_command.unwrap().parts),
|
||||
Span::new(13, 22)
|
||||
);
|
||||
|
||||
let parse_third_command =
|
||||
command_at_cursor_position(line, EscapeChar::Backslash, ByteOffset::from(28));
|
||||
assert!(parse_third_command.is_some());
|
||||
assert_eq!(
|
||||
Span::from_list(&parse_third_command.unwrap().parts),
|
||||
Span::new(27, 44)
|
||||
);
|
||||
|
||||
let parse_on_boundary =
|
||||
command_at_cursor_position(line, EscapeChar::Backslash, ByteOffset::from(25));
|
||||
assert!(parse_on_boundary.is_none());
|
||||
|
||||
let parse_out_of_range =
|
||||
command_at_cursor_position(line, EscapeChar::Backslash, ByteOffset::from(47));
|
||||
assert!(parse_out_of_range.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completes_command_names() {
|
||||
assert_eq!(
|
||||
location("cargo", CommandRegistry::default(), 3),
|
||||
vec![LocationType::Command {
|
||||
is_recognized: false,
|
||||
parsed_token: ParsedToken::new("car")
|
||||
}]
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
location("cargo", CommandRegistry::default(), 5),
|
||||
vec![LocationType::Command {
|
||||
is_recognized: true,
|
||||
parsed_token: ParsedToken::new("cargo")
|
||||
}]
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
location("cd path/to | echo 1", CommandRegistry::default(), 13),
|
||||
vec![LocationType::Command {
|
||||
is_recognized: true,
|
||||
parsed_token: ParsedToken::empty()
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completes_unregistered_command_names() {
|
||||
assert_eq!(
|
||||
location("warp", CommandRegistry::empty(), 4),
|
||||
vec![LocationType::Command {
|
||||
is_recognized: false,
|
||||
parsed_token: ParsedToken::new("warp")
|
||||
}]
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
location("echo hola | sdfsd", CommandRegistry::default(), 17),
|
||||
vec![LocationType::Command {
|
||||
is_recognized: false,
|
||||
parsed_token: ParsedToken::new("sdfsd")
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completes_argument_command() {
|
||||
let command = "cd ".spanned(Span::new(0, 2));
|
||||
let cursor_at_whitespace = Span::new(3, 3);
|
||||
assert_eq!(command.span.slice(command.item), "cd");
|
||||
assert_eq!(
|
||||
command.span.until(cursor_at_whitespace).slice(command.item),
|
||||
"cd "
|
||||
);
|
||||
assert_eq!(
|
||||
location(
|
||||
command.item,
|
||||
CommandRegistry::empty(),
|
||||
cursor_at_whitespace.end()
|
||||
),
|
||||
vec![
|
||||
LocationType::Argument {
|
||||
command_name: ("cd".to_string().spanned(command.span)),
|
||||
argument_name: None,
|
||||
parsed_token: ParsedToken::empty()
|
||||
},
|
||||
LocationType::Flag {
|
||||
command_name: "cd".to_string().spanned(command.span),
|
||||
flag_name: None
|
||||
}
|
||||
]
|
||||
);
|
||||
|
||||
let command = "echo hola | clang ".spanned(Span::new(12, 17));
|
||||
let cursor_at_whitespace = Span::new(18, 18);
|
||||
assert_eq!(command.span.slice(command.item), "clang");
|
||||
assert_eq!(
|
||||
command.span.until(cursor_at_whitespace).slice(command.item),
|
||||
"clang "
|
||||
);
|
||||
assert_eq!(
|
||||
location(
|
||||
command.item,
|
||||
CommandRegistry::default(),
|
||||
cursor_at_whitespace.end()
|
||||
),
|
||||
vec![
|
||||
LocationType::Argument {
|
||||
command_name: ("clang".to_string().spanned(command.span)),
|
||||
argument_name: None,
|
||||
parsed_token: ParsedToken::empty(),
|
||||
},
|
||||
LocationType::Flag {
|
||||
command_name: "clang".to_string().spanned(command.span),
|
||||
flag_name: None
|
||||
}
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completes_flags_having_one_hyphen() {
|
||||
assert_eq!(
|
||||
location("bundle -", CommandRegistry::default(), 8),
|
||||
vec![LocationType::Flag {
|
||||
command_name: "bundle".to_owned().spanned(Span::new(0, 6)),
|
||||
flag_name: Some("-".to_owned().spanned(Span::new(7, 8)))
|
||||
}]
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
location("git add -", CommandRegistry::default(), 9),
|
||||
vec![
|
||||
LocationType::Argument {
|
||||
command_name: ("git add".to_string().spanned(Span::new(0, 7))),
|
||||
argument_name: None,
|
||||
parsed_token: ParsedToken::new("-")
|
||||
},
|
||||
LocationType::Flag {
|
||||
command_name: "git add".to_string().spanned(Span::new(0, 7)),
|
||||
flag_name: Some("-".to_owned().spanned(Span::new(8, 9)))
|
||||
}
|
||||
]
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
location("echo hola | clang -", CommandRegistry::default(), 19),
|
||||
vec![
|
||||
LocationType::Argument {
|
||||
command_name: ("clang".to_string().spanned(Span::new(12, 17))),
|
||||
argument_name: None,
|
||||
parsed_token: ParsedToken::new("-")
|
||||
},
|
||||
LocationType::Flag {
|
||||
command_name: "clang".to_owned().spanned(Span::new(12, 17)),
|
||||
flag_name: Some("-".to_owned().spanned(Span::new(18, 19)))
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completes_flag_argument_after_equal_sign_no_value() {
|
||||
let cmd = "test".to_string().spanned(Span::new(0, 4));
|
||||
let registry = create_test_command_registry([test_signature()]);
|
||||
assert_eq!(
|
||||
location("test --long=", registry, 12),
|
||||
vec![LocationType::Argument {
|
||||
command_name: cmd,
|
||||
argument_name: Some("--long".to_owned()),
|
||||
parsed_token: ParsedToken::new(""),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completes_flag_argument_after_equal_sign_with_partial_value() {
|
||||
let cmd = "test".to_string().spanned(Span::new(0, 4));
|
||||
let registry = create_test_command_registry([test_signature()]);
|
||||
assert_eq!(
|
||||
location("test --long=lo", registry, 14),
|
||||
vec![LocationType::Argument {
|
||||
command_name: cmd,
|
||||
argument_name: Some("--long".to_owned()),
|
||||
parsed_token: ParsedToken::new("lo"),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completes_flag_argument_after_equal_sign_with_preceding_switch() {
|
||||
let cmd = "test".to_string().spanned(Span::new(0, 4));
|
||||
let registry = create_test_command_registry([test_signature()]);
|
||||
assert_eq!(
|
||||
location("test -r --long=", registry, 15),
|
||||
vec![LocationType::Argument {
|
||||
command_name: cmd,
|
||||
argument_name: Some("--long".to_owned()),
|
||||
parsed_token: ParsedToken::new(""),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completes_flag_argument_after_equal_sign_with_multiple_preceding_flags() {
|
||||
let cmd = "test".to_string().spanned(Span::new(0, 4));
|
||||
let registry = create_test_command_registry([test_signature()]);
|
||||
assert_eq!(
|
||||
location("test --not-long=bar -r --long=", registry, 30),
|
||||
vec![LocationType::Argument {
|
||||
command_name: cmd,
|
||||
argument_name: Some("--long".to_owned()),
|
||||
parsed_token: ParsedToken::new(""),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completes_flag_argument_after_equal_sign_with_preceding_space_delimited_flag() {
|
||||
let cmd = "test".to_string().spanned(Span::new(0, 4));
|
||||
let registry = create_test_command_registry([test_signature()]);
|
||||
assert_eq!(
|
||||
location("test --not-long bar --long=", registry, 27),
|
||||
vec![LocationType::Argument {
|
||||
command_name: cmd,
|
||||
argument_name: Some("--long".to_owned()),
|
||||
parsed_token: ParsedToken::new(""),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completes_after_completed_equal_sign_flag() {
|
||||
let cmd = "test".to_string().spanned(Span::new(0, 4));
|
||||
let registry = create_test_command_registry([test_signature()]);
|
||||
assert_eq!(
|
||||
location("test --long=foo ", registry, 16),
|
||||
vec![
|
||||
LocationType::Argument {
|
||||
command_name: cmd.clone(),
|
||||
argument_name: None,
|
||||
parsed_token: ParsedToken::empty(),
|
||||
},
|
||||
LocationType::Flag {
|
||||
command_name: cmd,
|
||||
flag_name: None,
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completes_flag_argument_after_equal_sign_with_two_preceding_switches() {
|
||||
let cmd = "test".to_string().spanned(Span::new(0, 4));
|
||||
let registry = create_test_command_registry([test_signature()]);
|
||||
assert_eq!(
|
||||
location("test -r -V --long=", registry, 18),
|
||||
vec![LocationType::Argument {
|
||||
command_name: cmd,
|
||||
argument_name: Some("--long".to_owned()),
|
||||
parsed_token: ParsedToken::new(""),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completes_flag_argument_with_all_three_flag_styles() {
|
||||
let cmd = "test".to_string().spanned(Span::new(0, 4));
|
||||
let registry = create_test_command_registry([test_signature()]);
|
||||
assert_eq!(
|
||||
location("test -r --not-long bar --long=", registry, 30),
|
||||
vec![LocationType::Argument {
|
||||
command_name: cmd,
|
||||
argument_name: Some("--long".to_owned()),
|
||||
parsed_token: ParsedToken::new(""),
|
||||
}]
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
use crate::{
|
||||
completer::TopLevelCommandCaseSensitivity,
|
||||
signatures::{get_matching_signature_for_input, CommandRegistry},
|
||||
};
|
||||
|
||||
/// Returns the name of the argument that should be given at `idx` for the given command.
|
||||
pub(super) fn argument_name_at_index_for_command(
|
||||
command: &str,
|
||||
idx: usize,
|
||||
command_registry: &CommandRegistry,
|
||||
// TODO(CORE-2810)
|
||||
_command_case_sensitivity: TopLevelCommandCaseSensitivity,
|
||||
) -> Option<String> {
|
||||
get_matching_signature_for_input(command, command_registry).and_then(|(found_signature, _)| {
|
||||
found_signature
|
||||
.arguments
|
||||
.get(idx)
|
||||
.map(|arg| arg.name.clone())
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use crate::completer::{
|
||||
matchers::MatchStrategy,
|
||||
suggest::{MatchedSuggestion, Priority, Suggestion, SuggestionType},
|
||||
};
|
||||
|
||||
use crate::parsers::ParsedToken;
|
||||
use itertools::Itertools;
|
||||
use smol_str::SmolStr;
|
||||
|
||||
pub fn suggestions(
|
||||
matcher: MatchStrategy,
|
||||
env_vars: &HashSet<SmolStr>,
|
||||
parsed_token: &ParsedToken,
|
||||
) -> Vec<MatchedSuggestion> {
|
||||
let var_to_complete = parsed_token;
|
||||
|
||||
if !var_to_complete.as_str().starts_with('$') {
|
||||
return Default::default();
|
||||
}
|
||||
|
||||
let var_to_complete = &var_to_complete.as_str()[1..];
|
||||
|
||||
env_vars
|
||||
.iter()
|
||||
.filter_map(|name| {
|
||||
matcher
|
||||
.get_match_type(var_to_complete, name)
|
||||
.map(|match_type| {
|
||||
let suggestion_text = format!("${name}");
|
||||
let suggestion = Suggestion::with_same_display_and_replacement(
|
||||
suggestion_text,
|
||||
None,
|
||||
SuggestionType::Variable,
|
||||
Priority::default(),
|
||||
);
|
||||
MatchedSuggestion::new(suggestion, match_type)
|
||||
})
|
||||
})
|
||||
.sorted_by(MatchedSuggestion::cmp_by_display)
|
||||
.collect()
|
||||
}
|
||||
@@ -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 galaxy_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 = galaxyui::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 = galaxyui::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 = galaxyui::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 =
|
||||
galaxyui::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 = galaxyui::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 = galaxyui::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 = galaxyui::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 = galaxyui::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 = galaxyui::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 =
|
||||
galaxyui::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 = galaxyui::r#async::block_on(expand_command_aliases("ALIASFORTEST ", false, &ctx));
|
||||
assert_eq!(result.expanded_command_line, "test ");
|
||||
|
||||
let result = galaxyui::r#async::block_on(expand_command_aliases("aliasfortest ", false, &ctx));
|
||||
assert_eq!(result.expanded_command_line, "test ");
|
||||
|
||||
let result = galaxyui::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 = galaxyui::r#async::block_on(expand_command_aliases("ALIASFORTEST ", false, &ctx));
|
||||
assert_eq!(result.expanded_command_line, "ALIASFORTEST ");
|
||||
|
||||
let result = galaxyui::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 = galaxyui::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 = galaxyui::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 = galaxyui::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 galaxy_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 galaxy_core::command::ExitCode;
|
||||
use galaxy_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 galaxy_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);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
pub mod completer;
|
||||
pub mod meta;
|
||||
pub mod parsers;
|
||||
pub mod signatures;
|
||||
pub mod util;
|
||||
|
||||
/// Stores the vector of parsed commands at a particular point in time (for some
|
||||
/// buffer text and completion context).
|
||||
#[derive(Clone)]
|
||||
struct ParsedCommandsSnapshot<'a, T: completer::CompletionContext> {
|
||||
buffer_text: String,
|
||||
parsed_commands: Vec<parsers::LiteCommand>,
|
||||
completion_context: &'a T,
|
||||
}
|
||||
|
||||
/// Stores the vector of parsed tokens (for all relevant commands in buffer)
|
||||
/// at a particular point in time (for some buffer text and completion context),
|
||||
/// so we can cache results.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ParsedTokensSnapshot {
|
||||
pub buffer_text: String,
|
||||
pub parsed_tokens: Vec<ParsedTokenData>,
|
||||
}
|
||||
|
||||
/// Stores all relevant data to describe a single parsed token in the context
|
||||
/// of a single command (not across commands).
|
||||
/// Example for `ls -a && git checkout -b`:
|
||||
/// We'd have (pseudo-code):
|
||||
/// [
|
||||
/// {
|
||||
/// token: {value: "ls", span: (0, 2)},
|
||||
/// token_index: 0,
|
||||
/// token_description: {
|
||||
/// suggestion_type: SuggestionType::Command,
|
||||
/// ...
|
||||
/// }
|
||||
/// },
|
||||
/// {
|
||||
/// token: {value: "-a", span: (3, 5)},
|
||||
/// token_index: 1,
|
||||
/// ...
|
||||
/// },
|
||||
/// {
|
||||
/// token: {value: "git", span: (9, 12)},
|
||||
/// token_index: 0,
|
||||
/// ...
|
||||
/// },
|
||||
/// {
|
||||
/// token: {value: "checkout", span: (13, 21)},
|
||||
/// token_index: 1,
|
||||
/// ...
|
||||
/// },
|
||||
/// {
|
||||
/// token: {value: "-b", span: (22, 24)},
|
||||
/// token_index: 2,
|
||||
/// ...
|
||||
/// },
|
||||
/// ]
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ParsedTokenData {
|
||||
pub token: meta::Spanned<String>,
|
||||
pub token_index: usize, // relative to a specific command
|
||||
pub token_description: Option<completer::Description>,
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
use std::cmp::Ordering;
|
||||
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Ord, PartialOrd, Hash)]
|
||||
pub struct Span {
|
||||
start: usize,
|
||||
end: usize,
|
||||
}
|
||||
|
||||
impl From<(usize, usize)> for Span {
|
||||
fn from((start, end): (usize, usize)) -> Span {
|
||||
Span::new(start, end)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&Span> for Span {
|
||||
fn from(span: &Span) -> Span {
|
||||
*span
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Option<Span>> for Span {
|
||||
fn from(input: Option<Span>) -> Span {
|
||||
input.unwrap_or_else(|| Span::new(0, 0))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Span> for std::ops::Range<usize> {
|
||||
fn from(input: Span) -> std::ops::Range<usize> {
|
||||
let start = input.start;
|
||||
let end = input.end;
|
||||
|
||||
std::ops::Range { start, end }
|
||||
}
|
||||
}
|
||||
|
||||
impl Span {
|
||||
/// Creates a new `Span` that has 0 start and 0 end.
|
||||
pub fn unknown() -> Span {
|
||||
Span::new(0, 0)
|
||||
}
|
||||
|
||||
pub fn for_char(pos: usize) -> Span {
|
||||
Span {
|
||||
start: pos,
|
||||
end: pos + 1,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn until(&self, other: impl Into<Span>) -> Span {
|
||||
let other = other.into();
|
||||
|
||||
Span::new(self.start, other.end)
|
||||
}
|
||||
|
||||
pub fn from_list(list: &[impl HasSpan]) -> Span {
|
||||
let mut iterator = list.iter();
|
||||
|
||||
match iterator.next() {
|
||||
None => Span::new(0, 0),
|
||||
Some(first) => {
|
||||
let last = iterator.last().unwrap_or(first);
|
||||
|
||||
Span::new(first.span().start, last.span().end)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new(start: usize, end: usize) -> Span {
|
||||
assert!(
|
||||
end >= start,
|
||||
"Can't create a Span whose end < start, start={start}, end={end}"
|
||||
);
|
||||
|
||||
Span { start, end }
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.start == self.end
|
||||
}
|
||||
|
||||
pub fn skip(&self, n_chars: usize) -> Span {
|
||||
Span::new(self.start + n_chars, self.end)
|
||||
}
|
||||
|
||||
pub fn distance(&self) -> usize {
|
||||
self.end - self.start
|
||||
}
|
||||
|
||||
pub fn start(&self) -> usize {
|
||||
self.start
|
||||
}
|
||||
|
||||
pub fn end(&self) -> usize {
|
||||
self.end
|
||||
}
|
||||
|
||||
pub fn slice<'a>(&self, source: &'a str) -> &'a str {
|
||||
let start = self.start;
|
||||
let end = self.end;
|
||||
|
||||
&source[start..end]
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialOrd<usize> for Span {
|
||||
fn partial_cmp(&self, other: &usize) -> Option<Ordering> {
|
||||
(self.end - self.start).partial_cmp(other)
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq<usize> for Span {
|
||||
fn eq(&self, other: &usize) -> bool {
|
||||
(self.end - self.start) == *other
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Hash)]
|
||||
pub struct Spanned<T> {
|
||||
pub span: Span,
|
||||
pub item: T,
|
||||
}
|
||||
|
||||
impl<T> Spanned<T> {
|
||||
pub fn map<U>(self, input: impl FnOnce(T) -> U) -> Spanned<U> {
|
||||
let span = self.span;
|
||||
|
||||
let mapped = input(self.item);
|
||||
mapped.spanned(span)
|
||||
}
|
||||
}
|
||||
|
||||
pub trait SpannedItem: Sized {
|
||||
fn spanned(self, span: impl Into<Span>) -> Spanned<Self> {
|
||||
Spanned {
|
||||
item: self,
|
||||
span: span.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn spanned_unknown(self) -> Spanned<Self> {
|
||||
Spanned {
|
||||
item: self,
|
||||
span: Span::unknown(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> SpannedItem for T {}
|
||||
|
||||
impl<T> std::ops::Deref for Spanned<T> {
|
||||
type Target = T;
|
||||
|
||||
/// Shorthand to deref to the contained value
|
||||
fn deref(&self) -> &T {
|
||||
&self.item
|
||||
}
|
||||
}
|
||||
|
||||
pub trait HasSpan {
|
||||
fn span(&self) -> Span;
|
||||
}
|
||||
|
||||
impl<T, E> HasSpan for Result<T, E>
|
||||
where
|
||||
T: HasSpan,
|
||||
{
|
||||
fn span(&self) -> Span {
|
||||
match self {
|
||||
Result::Ok(val) => val.span(),
|
||||
Result::Err(_) => Span::unknown(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> HasSpan for Spanned<T> {
|
||||
fn span(&self) -> Span {
|
||||
self.span
|
||||
}
|
||||
}
|
||||
|
||||
pub trait IntoSpanned {
|
||||
type Output: HasFallibleSpan;
|
||||
|
||||
fn into_spanned(self, span: impl Into<Span>) -> Self::Output;
|
||||
}
|
||||
|
||||
impl<T: HasFallibleSpan> IntoSpanned for T {
|
||||
type Output = T;
|
||||
fn into_spanned(self, _span: impl Into<Span>) -> Self::Output {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
pub trait HasFallibleSpan {
|
||||
fn maybe_span(&self) -> Option<Span>;
|
||||
}
|
||||
|
||||
impl HasFallibleSpan for bool {
|
||||
fn maybe_span(&self) -> Option<Span> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl HasFallibleSpan for () {
|
||||
fn maybe_span(&self) -> Option<Span> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> HasFallibleSpan for T
|
||||
where
|
||||
T: HasSpan,
|
||||
{
|
||||
fn maybe_span(&self) -> Option<Span> {
|
||||
Some(HasSpan::span(self))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "meta_test.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,27 @@
|
||||
use super::*;
|
||||
|
||||
/*
|
||||
0 1 2 3
|
||||
w a r p
|
||||
-------
|
||||
0 4 << the span for the string "warp" is (0, 4)
|
||||
|
||||
Spanned {
|
||||
item: String::new("warp"), << warp string
|
||||
span: Span::new(0, 4) << span
|
||||
}
|
||||
|
||||
or >> String::new("warp").spanned(Span::new(0, 4)) */
|
||||
fn warp() -> Spanned<String> {
|
||||
String::from("warp").spanned(Span::new(0, 4))
|
||||
}
|
||||
|
||||
fn empty() -> Spanned<String> {
|
||||
String::new().spanned_unknown()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn knows_distances() {
|
||||
assert!(warp().span.distance() == 4);
|
||||
assert!(empty().span.distance() == 0);
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
# Basic parser
|
||||
|
||||
This parser is a type driven, recursive descent parser. This work in progress guide goes over a friendly overview of the workings of it with examples. In some places `Did you know?` notes are included, they are good to know tips because the parser relies on them.
|
||||
|
||||
# Steps
|
||||
1. Lex. - [What happens when we start the parse](#what-happens-when-we-start-the-parse)
|
||||
2. Lite Parse. - [Working with the tokens](#working-with-the-tokens)
|
||||
3. Type driven full parse. - [Full parse](#)
|
||||
|
||||
## What happens when we start the parse?
|
||||
|
||||
Let's say we are interested in parsing the input `warp --disable-telemetry`. Command calls generally have the form `cmd <arg1> <arg2> <argN>` where `<arg>`s are positional parameters. Let's use the primary functions of the parser and inspect. The first step we do is calling the tokenizer (the function `lex` here):
|
||||
|
||||
```rust
|
||||
let input = "warp --disable_telemetry";
|
||||
let start_offset = 0;
|
||||
|
||||
let (tokens, _) = lex(input, start_offset);
|
||||
println!("{:#?}", tokens);
|
||||
```
|
||||
|
||||
The output is:
|
||||
|
||||
```rust
|
||||
(
|
||||
[
|
||||
Token {
|
||||
contents: Baseline(
|
||||
"warp",
|
||||
),
|
||||
span: Span {
|
||||
start: 0,
|
||||
end: 4,
|
||||
},
|
||||
},
|
||||
Token {
|
||||
contents: Space,
|
||||
span: Span {
|
||||
start: 4,
|
||||
end: 5,
|
||||
},
|
||||
},
|
||||
Token {
|
||||
contents: Baseline(
|
||||
"--disable-telemetry",
|
||||
),
|
||||
span: Span {
|
||||
start: 5,
|
||||
end: 24,
|
||||
},
|
||||
},
|
||||
],
|
||||
None,
|
||||
)
|
||||
```
|
||||
|
||||
In effect, we receive here the input source *tokenized*. The `start_offset` is used by the parser to calculate the spans of each bare word. Inspecting a little more we can notice the tokens with a `span` field on them. The `Span` [struct type](../meta.rs) has the fields `start` and `end` number that are helpful for knowing where something is.
|
||||
|
||||
#### Did you know? The `Span` struct type.
|
||||
|
||||
Let's use the span numbers from the output above, and use the `Span` struct associated `slice` function. This function takes a string as input and will return a slice of it using the `Span`'s `start` and `end` values. Let's create three `Span`s with the numbers from the output above and slice each from the input string fed to the lexer.
|
||||
|
||||
```rust
|
||||
let input = "warp --disable-telemetry";
|
||||
|
||||
let word1 = Span::new(0,4);
|
||||
let word2 = Span::new(4,5);
|
||||
let word3 = Span::new(5,24);
|
||||
|
||||
assert_eq!(word1.slice(input), "warp");
|
||||
assert_eq!(word2.slice(input), " ");
|
||||
assert_eq!(word3.slice(input), "--disable-telemetry");
|
||||
```
|
||||
|
||||
## Working with the tokens
|
||||
|
||||
The next step of the basic parser isn't all that different from lexing/parsing from traditional parsing. The task right now is to understand the boundaries of the tokens and get the general forms ready for a full parse. We are not interested here in doing anything more just yet since each of these tokens can be passed to commands that have no signature registered (*more on this later*). We call this step the `Lite` parse step.
|
||||
|
||||
A very simplistic view of the grammar rules below:
|
||||
|
||||
```
|
||||
LiteRootNode := LiteGroup
|
||||
LiteGroup := LitePipeline (';' LitePipeline)*
|
||||
LitePipeline := LiteCommand ('|' LiteCommand)*
|
||||
LiteCommand := argument+
|
||||
// (*more grammar later*)
|
||||
```
|
||||
|
||||
These are represented as structs that basic parser generates, they are:
|
||||
|
||||
```rust
|
||||
pub struct LiteRootNode {
|
||||
pub groups: Vec<LiteGroup>,
|
||||
}
|
||||
|
||||
pub struct LiteGroup {
|
||||
pub pipelines: Vec<LitePipeline>,
|
||||
}
|
||||
|
||||
pub struct LitePipeline {
|
||||
pub commands: Vec<LiteCommand>,
|
||||
}
|
||||
|
||||
pub struct LiteCommand {
|
||||
// this is important!
|
||||
pub parts: Vec<Spanned<String>>,
|
||||
pub post_whitespace: Option<Span>,
|
||||
}
|
||||
```
|
||||
|
||||
#### Did you know? The `Spanned<T>` generic struct.
|
||||
|
||||
The `LiteCommand` has a `parts` field that holds a vector of `Spanned<String>`. We mentioned about the `Span` type before. Here, we talk about a generic `Spanned<T>` that allows wrapping any `T` with a `Span` value along with it. Here is the type along with a few examples using it's helper functions.
|
||||
|
||||
```rust
|
||||
pub struct Spanned<T> {
|
||||
pub span: Span,
|
||||
pub item: T,
|
||||
}
|
||||
|
||||
let example = Spanned { item: String::from("warp"), span: Span::new(0,4) };
|
||||
assert_eq!(example.item, "warp".to_string());
|
||||
assert_eq!(example.span, Span::new(0,4));
|
||||
|
||||
let example = String::from("warp").spanned(Span::new(0,4));
|
||||
assert_eq!(example.item, "warp".to_string());
|
||||
assert_eq!(example.span, Span::new(0,4));
|
||||
|
||||
let example = "warp -p --disable-telemetry";
|
||||
|
||||
let full_span = Span::new(0, example.len());
|
||||
let first_flag_span = Span::new(5,7);
|
||||
|
||||
assert_eq!(first_flag_span.slice(example), "-p");
|
||||
assert_eq!(first_flag_span.until(full_span), Span::new(5,27));
|
||||
assert_eq!(first_flag_span.until(full_span).slice(example), "-p --disable-telemetry");
|
||||
|
||||
```
|
||||
|
||||
This is useful because as soon as the `lite` parse step happens. We get as output everything we need with `spans` correctly calculated. Let's go ahead and do a lite parse (the function `parse_tokens`) to our original example using as input the tokens generated by the lexer when processing the input `warp --disable-telemetry`, like so:
|
||||
|
||||
```rust
|
||||
let input = "warp --disable-telemetry";
|
||||
let start_offset = 0;
|
||||
|
||||
let (tokens, _) = lex(input, start_offset);
|
||||
let (lite_node, _) = parse_tokens(tokens);
|
||||
|
||||
let expected_word1 = String::from("warp").spanned(Span::new(0,4));
|
||||
let expected_word2 = String::from("--disable-telemetry").spanned(Span::new(5,24));
|
||||
|
||||
assert_eq!(lite_node.groups[0].pipelines[0].commands[0].parts, vec![expected_word1, expected_word2]);
|
||||
assert_eq!(lite_node.groups[0].pipelines[0].commands.len(), 1);
|
||||
|
||||
println!("{:#?}", lite_node);
|
||||
```
|
||||
|
||||
We get a nice lite node:
|
||||
```rust
|
||||
LiteRootNode {
|
||||
groups: [
|
||||
LiteGroup {
|
||||
pipelines: [
|
||||
LitePipeline {
|
||||
commands: [
|
||||
LiteCommand {
|
||||
parts: [
|
||||
Spanned {
|
||||
span: Span {
|
||||
start: 0,
|
||||
end: 4,
|
||||
},
|
||||
item: "warp",
|
||||
},
|
||||
Spanned {
|
||||
span: Span {
|
||||
start: 5,
|
||||
end: 24,
|
||||
},
|
||||
item: "--disable-telemetry",
|
||||
},
|
||||
],
|
||||
post_whitespace: None,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
For more involved inputs (say commands separated by `|` and/or having `;`) the lite parser will effectively create the necessary `LitePipeline`s to express it, let's explore what happens if we lite parse the input `warp config-set --extension-path="/path/to/dir" ; echo $WARP_VAR"` (*two pipelines here due to the ; character*), like so:
|
||||
|
||||
```rust
|
||||
let input = "warp config-set --extension-path=\"/path/to/dir\" ; echo $WARP_VAR";
|
||||
let start_offset = 0;
|
||||
|
||||
let (tokens, _) = lex(input, start_offset);
|
||||
let (lite_node, _) = parse_tokens(tokens);
|
||||
|
||||
println!("{:#?}", lite_node);
|
||||
```
|
||||
|
||||
```rust
|
||||
LiteRootNode {
|
||||
groups: [
|
||||
LiteGroup {
|
||||
pipelines: [
|
||||
LitePipeline {
|
||||
commands: [
|
||||
LiteCommand {
|
||||
parts: [
|
||||
Spanned {
|
||||
span: Span {
|
||||
start: 0,
|
||||
end: 4,
|
||||
},
|
||||
item: "warp",
|
||||
},
|
||||
Spanned {
|
||||
span: Span {
|
||||
start: 5,
|
||||
end: 15,
|
||||
},
|
||||
item: "config-set",
|
||||
},
|
||||
Spanned {
|
||||
span: Span {
|
||||
start: 16,
|
||||
end: 47,
|
||||
},
|
||||
item: "--extension-path=\"/path/to/dir\"",
|
||||
},
|
||||
],
|
||||
post_whitespace: Some(
|
||||
Span {
|
||||
start: 47,
|
||||
end: 48,
|
||||
},
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
LitePipeline {
|
||||
commands: [
|
||||
LiteCommand {
|
||||
parts: [
|
||||
Spanned {
|
||||
span: Span {
|
||||
start: 50,
|
||||
end: 54,
|
||||
},
|
||||
item: "echo",
|
||||
},
|
||||
Spanned {
|
||||
span: Span {
|
||||
start: 55,
|
||||
end: 64,
|
||||
},
|
||||
item: "$WARP_VAR",
|
||||
},
|
||||
],
|
||||
post_whitespace: None,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
## Type driven full parse
|
||||
|
||||
TODO
|
||||
@@ -0,0 +1,108 @@
|
||||
#![allow(dead_code)]
|
||||
use crate::meta::{Span, Spanned, SpannedItem};
|
||||
use getset::Getters;
|
||||
|
||||
#[derive(Getters, Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)]
|
||||
pub struct ParseError {
|
||||
#[get = "pub"]
|
||||
pub reason: ParseErrorReason,
|
||||
}
|
||||
|
||||
impl ParseError {
|
||||
pub fn unexpected_eof(expected: impl Into<String>, span: Span) -> ParseError {
|
||||
ParseError {
|
||||
reason: ParseErrorReason::Eof {
|
||||
expected: expected.into(),
|
||||
span,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn extra_tokens(actual: Spanned<impl Into<String>>) -> ParseError {
|
||||
let Spanned { span, item } = actual;
|
||||
|
||||
ParseError {
|
||||
reason: ParseErrorReason::ExtraTokens {
|
||||
actual: item.into().spanned(span),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn mismatch(expected: impl Into<String>, actual: Spanned<impl Into<String>>) -> ParseError {
|
||||
let Spanned { span, item } = actual;
|
||||
|
||||
ParseError {
|
||||
reason: ParseErrorReason::Mismatch {
|
||||
expected: expected.into(),
|
||||
actual: item.into().spanned(span),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn internal_error(message: Spanned<impl Into<String>>) -> ParseError {
|
||||
ParseError {
|
||||
reason: ParseErrorReason::InternalError {
|
||||
message: message.item.into().spanned(message.span),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn argument_error(command: Spanned<impl Into<String>>, kind: ArgumentError) -> ParseError {
|
||||
ParseError {
|
||||
reason: ParseErrorReason::ArgumentError {
|
||||
command: command.item.into().spanned(command.span),
|
||||
error: kind,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)]
|
||||
pub enum ParseErrorReason {
|
||||
Eof {
|
||||
expected: String,
|
||||
span: Span,
|
||||
},
|
||||
ExtraTokens {
|
||||
actual: Spanned<String>,
|
||||
},
|
||||
Mismatch {
|
||||
expected: String,
|
||||
actual: Spanned<String>,
|
||||
},
|
||||
InternalError {
|
||||
message: Spanned<String>,
|
||||
},
|
||||
ArgumentError {
|
||||
command: Spanned<String>,
|
||||
error: ArgumentError,
|
||||
},
|
||||
}
|
||||
|
||||
/// ArgumentError describes various ways that the parser could fail because of unexpected arguments.
|
||||
/// These errors correspond to problems that could be identified during expansion based on the
|
||||
/// signature of a command.
|
||||
#[derive(Debug, Eq, PartialEq, Clone, Ord, Hash, PartialOrd)]
|
||||
pub enum ArgumentError {
|
||||
/// The command specified a mandatory flag, but it was missing.
|
||||
MissingMandatoryFlag(String),
|
||||
/// The command specified a mandatory positional argument, but it was missing.
|
||||
MissingMandatoryPositional {
|
||||
name: Option<String>,
|
||||
positional_index: usize,
|
||||
},
|
||||
/// A flag was found, and it should have been followed by a value, but no value was found.
|
||||
MissingValueForName {
|
||||
/// The name of the flag/option
|
||||
name: Spanned<String>,
|
||||
/// The index of the missing argument for the option,
|
||||
/// e.g. if the flag was -D arg1 arg2, then missing_arg_index=0 corresponds to arg1
|
||||
missing_arg_index: usize,
|
||||
},
|
||||
/// An argument was found, but the command does not recognize it.
|
||||
UnexpectedArgument(Spanned<String>),
|
||||
/// An flag was found, but the command does not recognize it.
|
||||
UnexpectedFlag(Spanned<String>),
|
||||
/// A recognized flag had an invalid value.
|
||||
InvalidValueForFlag(Spanned<String>),
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
use warp_command_signatures::{Argument, ArgumentType, GeneratorName, Template, TemplateType};
|
||||
|
||||
use crate::meta::{HasSpan, Span, Spanned, SpannedItem};
|
||||
use crate::parsers::{ParsedExpression, ParsedToken};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ShellCommand {
|
||||
/// The name goes up to the last subcommand.
|
||||
/// For example, it would be "git checkout" if there are any args after "checkout", not "git".
|
||||
pub name: String,
|
||||
pub name_span: Span,
|
||||
pub args: CommandCallInfo,
|
||||
}
|
||||
|
||||
impl ShellCommand {
|
||||
pub fn new(name: ParsedToken, name_span: Span, full_span: Span) -> Self {
|
||||
let command_name = name.as_str().to_owned();
|
||||
let spanned_command = ParsedExpression::new(Expression::Command, name).spanned(name_span);
|
||||
Self {
|
||||
name: command_name,
|
||||
name_span,
|
||||
args: CommandCallInfo::new(spanned_command, full_span),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the last positional, if any, in the command.
|
||||
pub fn last_positional(&self) -> Option<Spanned<&ParsedToken>> {
|
||||
self.args
|
||||
.positionals
|
||||
.as_ref()
|
||||
.and_then(|positionals| positionals.last())
|
||||
.map(|expression| expression.item.value().spanned(expression.span))
|
||||
}
|
||||
|
||||
/// Returns the name and span of the last named argument (if any) in the command.
|
||||
pub fn last_named_argument(&self) -> Option<Spanned<NamedArgument<'_>>> {
|
||||
self.args.flags.as_ref().and_then(|named_args| {
|
||||
named_args
|
||||
.iter()
|
||||
.filter_map(|flag| match &flag.flag_type {
|
||||
FlagType::Argument {
|
||||
value: parsed_expression,
|
||||
..
|
||||
} => Some(
|
||||
NamedArgument {
|
||||
name: flag.name.as_str(),
|
||||
parsed_token: parsed_expression.value(),
|
||||
}
|
||||
.spanned(parsed_expression.span),
|
||||
),
|
||||
_ => None,
|
||||
})
|
||||
.fold(None, |acc, named_arg| match acc {
|
||||
None => Some(named_arg),
|
||||
Some(acc_named_arg) => {
|
||||
if named_arg.span.end() > acc_named_arg.span.end() {
|
||||
Some(named_arg)
|
||||
} else {
|
||||
Some(acc_named_arg)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct NamedArgument<'a> {
|
||||
pub name: &'a str,
|
||||
pub parsed_token: &'a ParsedToken,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ExternalCommand {
|
||||
pub name: ParsedToken,
|
||||
pub name_span: Span,
|
||||
pub args: CommandCallInfo,
|
||||
}
|
||||
|
||||
impl ExternalCommand {
|
||||
pub fn new(name: ParsedToken, name_span: Span, full_span: Span) -> Self {
|
||||
let command_name =
|
||||
ParsedExpression::new(Expression::Literal, name.clone()).spanned(full_span);
|
||||
Self {
|
||||
name,
|
||||
name_span,
|
||||
args: CommandCallInfo::new(command_name, full_span),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl HasSpan for ExternalCommand {
|
||||
fn span(&self) -> Span {
|
||||
self.name_span.until(self.args.span)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Command {
|
||||
/// A command that was successfully parsed via a signature in the `CommandRegistry`.
|
||||
Classified(ShellCommand),
|
||||
|
||||
/// A command that is not in the `CommandRegistry`.
|
||||
Unclassified(ExternalCommand),
|
||||
}
|
||||
|
||||
impl Command {
|
||||
pub fn command_name_span(&self) -> Spanned<&str> {
|
||||
match self {
|
||||
Command::Classified(shell_command) => {
|
||||
shell_command.name.as_str().spanned(shell_command.name_span)
|
||||
}
|
||||
Command::Unclassified(external_command) => external_command
|
||||
.name
|
||||
.as_str()
|
||||
.spanned(external_command.name_span),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn name_span(&self) -> Span {
|
||||
match self {
|
||||
Self::Classified(command) => command.name_span,
|
||||
Self::Unclassified(command) => command.name_span,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn last_token(&self) -> ParsedToken {
|
||||
let command_call_info = match self {
|
||||
Self::Classified(command) => &command.args,
|
||||
Self::Unclassified(command) => &command.args,
|
||||
};
|
||||
if command_call_info.ending_whitespace.is_some() {
|
||||
return ParsedToken::empty();
|
||||
}
|
||||
if let Some(positionals) = &command_call_info.positionals {
|
||||
if let Some(last) = positionals.last() {
|
||||
return last.parsed_token.clone();
|
||||
}
|
||||
}
|
||||
command_call_info.command_name.parsed_token.clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CommandCallInfo {
|
||||
pub command_name: Spanned<ParsedExpression>,
|
||||
pub positionals: Option<Vec<Spanned<ParsedExpression>>>,
|
||||
pub flags: Option<Flags>,
|
||||
/// Any additional whitespace at the end of the command.
|
||||
pub ending_whitespace: Option<Span>,
|
||||
pub span: Span,
|
||||
}
|
||||
|
||||
impl CommandCallInfo {
|
||||
pub fn new(head: Spanned<ParsedExpression>, span: Span) -> CommandCallInfo {
|
||||
CommandCallInfo {
|
||||
command_name: head,
|
||||
positionals: None,
|
||||
flags: None,
|
||||
ending_whitespace: None,
|
||||
span,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ArgType {
|
||||
File,
|
||||
Folder,
|
||||
Generator(GeneratorName),
|
||||
}
|
||||
|
||||
impl ArgType {
|
||||
/// Returns a list of argument types we should validate.
|
||||
/// If there are any argument types that we haven't implemented validation for, returns empty vec
|
||||
/// because we should assume the argument is valid.
|
||||
pub fn get_arg_types_to_validate_from_arg_signature(arg_signature: &Argument) -> Vec<Self> {
|
||||
let mut arg_types = vec![];
|
||||
if arg_signature.skip_generator_validation {
|
||||
return arg_types;
|
||||
}
|
||||
for argument_type in arg_signature.argument_types.iter() {
|
||||
match argument_type {
|
||||
ArgumentType::Template(Template {
|
||||
type_name: TemplateType::FilesAndFolders,
|
||||
..
|
||||
}) => arg_types.extend(vec![ArgType::File, ArgType::Folder]),
|
||||
ArgumentType::Template(Template {
|
||||
type_name: TemplateType::Files { must_exist: true },
|
||||
..
|
||||
}) => arg_types.push(ArgType::File),
|
||||
ArgumentType::Template(Template {
|
||||
type_name: TemplateType::Folders { must_exist: true },
|
||||
..
|
||||
}) => arg_types.push(ArgType::Folder),
|
||||
ArgumentType::Generator(generator_name) => {
|
||||
arg_types.push(ArgType::Generator(generator_name.clone()))
|
||||
}
|
||||
// We don't want to validate against hard coded suggestions.
|
||||
ArgumentType::Suggestion(_) => (),
|
||||
// By the time we do arg validation, the command already has aliases expanded.
|
||||
// We don't need to do anything here.
|
||||
ArgumentType::Alias(_) => (),
|
||||
_ => {
|
||||
// If we encounter an argument type that we haven't implemented validation for,
|
||||
// we should assume the argument is valid.
|
||||
// Return empty vec because there's no need to validate known argument types.
|
||||
return vec![];
|
||||
}
|
||||
}
|
||||
}
|
||||
arg_types
|
||||
}
|
||||
}
|
||||
|
||||
/// Identifies the expression type of a parsed token.
|
||||
#[derive(Debug, Clone, Eq, PartialEq)]
|
||||
pub enum Expression {
|
||||
/// The expression is a literal value that will be interpreted by the command. This could be
|
||||
/// a subcommand, an argument, or a flag that will be assigned meaning once we tie it to a
|
||||
/// specific completion spec.
|
||||
Literal,
|
||||
/// The expression is an environment variable.
|
||||
Variable,
|
||||
/// The expression is a top-level command (such as `git`).
|
||||
Command,
|
||||
/// The expression is an argument that has been identified by the command signature that we have an
|
||||
/// exhaustive list of arg types to validate against.
|
||||
/// Contains the exhaustive list of arg types we should validate against, where at least one must pass validation.
|
||||
/// Note that if the argument can be a type that we don't have a validation for, it will be parsed as Expression::Literal
|
||||
/// instead.
|
||||
/// For example, in `git checkout {arg}` the arg can be a file/folder/git branch. If we don't have a git branch validation,
|
||||
/// the arg would be parsed as a Literal instead of ValidatableArgument([file, folder]) because the list is not
|
||||
/// exhaustive.
|
||||
ValidatableArgument(Vec<ArgType>),
|
||||
/// We were not able to identify what part of the command is.
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum FlagType {
|
||||
/// The flag requires no argument, such as `git --help`
|
||||
NoArgument,
|
||||
/// The flag has an argument, such as `git checkout -b {BRANCH_NAME}`.
|
||||
Argument { value: Spanned<ParsedExpression> },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Flag {
|
||||
/// The name of the flag.
|
||||
pub name: String,
|
||||
pub name_span: Span,
|
||||
pub flag_type: FlagType,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct Flags {
|
||||
pub(crate) flags: Vec<Flag>,
|
||||
}
|
||||
|
||||
impl Flags {
|
||||
pub fn new() -> Flags {
|
||||
Default::default()
|
||||
}
|
||||
|
||||
pub fn iter(&self) -> impl Iterator<Item = &Flag> {
|
||||
self.flags.iter()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.flags.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl Flags {
|
||||
pub fn insert_flag_with_no_argument(&mut self, name: impl Into<String>, span: Span) {
|
||||
let name = name.into();
|
||||
self.flags.push(Flag {
|
||||
name,
|
||||
name_span: span,
|
||||
flag_type: FlagType::NoArgument,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn insert_flag_with_argument(
|
||||
&mut self,
|
||||
name: impl Into<String>,
|
||||
flag_span: Span,
|
||||
expr: Spanned<ParsedExpression>,
|
||||
) {
|
||||
self.flags.push(Flag {
|
||||
name: name.into(),
|
||||
name_span: flag_span,
|
||||
flag_type: FlagType::Argument { value: expr },
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
//! The "legacy" implementation of internal command parsing logic that depends on the legacy:
|
||||
//! command signature struct (`warp_command_signatures::Signature`).
|
||||
use itertools::Itertools;
|
||||
use warp_command_signatures::{DynamicCompletionData, IsArgumentOptional, Opt, Signature};
|
||||
|
||||
use crate::{
|
||||
completer::TopLevelCommandCaseSensitivity,
|
||||
meta::{HasSpan, Span, Spanned, SpannedItem},
|
||||
parsers::{
|
||||
hir::Flags, parse_arg, parse_dollar_expr, ArgumentError, FlagArgumentsCardinality,
|
||||
FlagSignature, ParsedExpression, ParsedToken,
|
||||
},
|
||||
signatures::CommandRegistry,
|
||||
};
|
||||
|
||||
use super::{
|
||||
hir::{Command, Expression, ShellCommand},
|
||||
parse_unclassified_command, LiteCommand, ParseError,
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
/// A `Signature` (and its corresponding generator) contained at a given index.
|
||||
pub struct SignatureAtTokenIndex<'a> {
|
||||
pub signature: &'a Signature,
|
||||
pub dynamic_completion_data: Option<&'a DynamicCompletionData>,
|
||||
pub token_index: usize,
|
||||
}
|
||||
|
||||
impl<'a> SignatureAtTokenIndex<'a> {
|
||||
pub fn new(
|
||||
signature: &'a Signature,
|
||||
dynamic_completion_data: Option<&'a DynamicCompletionData>,
|
||||
index: usize,
|
||||
) -> Self {
|
||||
SignatureAtTokenIndex {
|
||||
signature,
|
||||
dynamic_completion_data,
|
||||
token_index: index,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks whether a `LiteCommand` is in the registry.
|
||||
pub(super) fn parse_command(
|
||||
lite_cmd: &LiteCommand,
|
||||
tokens: &[&str],
|
||||
parser_scope: &CommandRegistry,
|
||||
command_case_sensitivity: TopLevelCommandCaseSensitivity,
|
||||
) -> (Option<Command>, Option<ParseError>) {
|
||||
let mut error: Option<ParseError> = None;
|
||||
|
||||
if lite_cmd.parts.is_empty() {
|
||||
(None, None)
|
||||
} else if let Some(found_signature) = parser_scope.signature_from_tokens(
|
||||
tokens,
|
||||
lite_cmd.post_whitespace.is_some(),
|
||||
command_case_sensitivity,
|
||||
) {
|
||||
let (internal_command, err) = parse_internal_command(
|
||||
lite_cmd,
|
||||
found_signature.signature,
|
||||
found_signature.token_index,
|
||||
);
|
||||
|
||||
error = error.or(err);
|
||||
(Some(Command::Classified(internal_command)), error)
|
||||
} else {
|
||||
let (command, error) = parse_unclassified_command(lite_cmd);
|
||||
(Some(command), error)
|
||||
}
|
||||
}
|
||||
|
||||
/// Does a full parse of an internal command using the lite-ly parse command as a starting point
|
||||
/// This main focus at this level is to understand what flags were passed in, what positional
|
||||
/// arguments were passed in, what rest arguments were passed in and to ensure that the basic
|
||||
/// requirements in terms of number of each were met.
|
||||
fn parse_internal_command(
|
||||
lite_cmd: &LiteCommand,
|
||||
signature: &Signature,
|
||||
mut idx: usize,
|
||||
) -> (ShellCommand, Option<ParseError>) {
|
||||
log::debug!("parsing internal command {lite_cmd:?}");
|
||||
|
||||
// This is a known internal command, so we need to work with the arguments and parse them according to the expected types
|
||||
let (name, name_span) = (
|
||||
lite_cmd.parts[0..(idx + 1)]
|
||||
.iter()
|
||||
.map(|x| x.item.clone())
|
||||
.collect::<Vec<String>>()
|
||||
.join(" "),
|
||||
Span::new(
|
||||
lite_cmd.parts[0].span.start(),
|
||||
lite_cmd.parts[idx].span.end(),
|
||||
),
|
||||
);
|
||||
|
||||
let mut internal_command = ShellCommand::new(ParsedToken(name), name_span, lite_cmd.span());
|
||||
internal_command.args.flags = Some(Flags::new());
|
||||
internal_command.args.ending_whitespace = lite_cmd.post_whitespace;
|
||||
|
||||
let mut current_positional = 0;
|
||||
let mut named = Flags::new();
|
||||
let mut positional = vec![];
|
||||
let mut error = None;
|
||||
idx += 1; // Start where the arguments begin
|
||||
|
||||
while idx < lite_cmd.parts.len() {
|
||||
if lite_cmd.parts[idx].item.starts_with('-') && lite_cmd.parts[idx].item.len() > 1 {
|
||||
let (named_types, err) =
|
||||
get_flag_signature_spec(signature, &internal_command, &lite_cmd.parts[idx]);
|
||||
|
||||
if err.is_none() {
|
||||
for FlagSignature {
|
||||
name: full_name,
|
||||
is_switch,
|
||||
arguments_cardinality,
|
||||
arguments,
|
||||
} in named_types
|
||||
{
|
||||
if is_switch {
|
||||
// Switch flag (without arguments)
|
||||
named.insert_flag_with_no_argument(full_name, lite_cmd.parts[idx].span);
|
||||
} else if let Some(eq_pos) = lite_cmd.parts[idx].item.find('=') {
|
||||
// Self-contained option (--key=value)
|
||||
let token = &lite_cmd.parts[idx];
|
||||
let value_offset = eq_pos + 1;
|
||||
|
||||
let value_span =
|
||||
Span::new(token.span.start() + value_offset, token.span.end());
|
||||
let value = token.item[value_offset..].to_string().spanned(value_span);
|
||||
// We expect there to be exactly one arg in the case of a --key=value flag.
|
||||
let arg_signature = arguments.first();
|
||||
let (arg, err) = parse_arg(&value, arg_signature);
|
||||
// Flag name span covers only the flag name (before '=').
|
||||
let flag_name_span =
|
||||
Span::new(token.span.start(), token.span.start() + eq_pos);
|
||||
named.insert_flag_with_argument(full_name, flag_name_span, arg);
|
||||
|
||||
error = error.or(err);
|
||||
} else if idx == lite_cmd.parts.len() - 1 {
|
||||
// Named argument with missing value
|
||||
error = error.or_else(|| {
|
||||
Some(ParseError::argument_error(
|
||||
lite_cmd.parts[0].clone(),
|
||||
ArgumentError::MissingValueForName {
|
||||
name: full_name.spanned(lite_cmd.parts[idx].span),
|
||||
missing_arg_index: 0,
|
||||
},
|
||||
))
|
||||
});
|
||||
} else {
|
||||
// Named argument with following value(s).
|
||||
// Since an option can have multiple arguments (any of which can be variadic),
|
||||
// we should exhaust as many following args as possible.
|
||||
let flag_idx = idx;
|
||||
|
||||
let end = match arguments_cardinality {
|
||||
FlagArgumentsCardinality::Variadic => lite_cmd.parts.len(),
|
||||
FlagArgumentsCardinality::Fixed(num_args) => flag_idx + num_args + 1,
|
||||
};
|
||||
let mut argument_idx = idx + 1;
|
||||
|
||||
// Exhaust as many args as we expect but stop early if we see another option.
|
||||
// Note that since we are incrementing index again in the outer loop. Let's check
|
||||
// boundary on the NEXT token rather than the current token.
|
||||
while argument_idx < end.min(lite_cmd.parts.len())
|
||||
&& !lite_cmd
|
||||
.parts
|
||||
.get(argument_idx)
|
||||
.is_some_and(|part| part.item.starts_with('-'))
|
||||
{
|
||||
let arg_signature_idx = argument_idx - flag_idx - 1;
|
||||
// Even though the completion spec technically allows for a variadic arg to not be the last arg,
|
||||
// it does not make sense and doesn't happen in practice, so we assume it's the last.
|
||||
let arg_signature = if arg_signature_idx >= arguments.len()
|
||||
&& matches!(
|
||||
arguments_cardinality,
|
||||
FlagArgumentsCardinality::Variadic
|
||||
) {
|
||||
arguments.last()
|
||||
} else {
|
||||
arguments.get(arg_signature_idx)
|
||||
};
|
||||
let (arg, err) =
|
||||
parse_arg(&lite_cmd.parts[argument_idx], arg_signature);
|
||||
named.insert_flag_with_argument(
|
||||
full_name.clone(),
|
||||
lite_cmd.parts[flag_idx].span,
|
||||
arg,
|
||||
);
|
||||
error = error.or(err);
|
||||
argument_idx += 1;
|
||||
}
|
||||
|
||||
// If there were a fixed number of arguments for the option, make sure
|
||||
// they were all exhausted. Otherwise, we're missing an arg.
|
||||
if matches!(arguments_cardinality, FlagArgumentsCardinality::Fixed(_))
|
||||
&& argument_idx != end
|
||||
{
|
||||
error = error.or_else(|| {
|
||||
Some(ParseError::argument_error(
|
||||
lite_cmd.parts[0].clone(),
|
||||
ArgumentError::MissingValueForName {
|
||||
name: full_name.spanned(lite_cmd.parts[flag_idx].span),
|
||||
missing_arg_index: argument_idx - flag_idx - 1,
|
||||
},
|
||||
))
|
||||
});
|
||||
}
|
||||
|
||||
// argument_idx here is one index overshoot of the last argument
|
||||
// token. Set the current index to be the index of last argument.
|
||||
idx = argument_idx - 1;
|
||||
|
||||
// We consumed the argument(s) for the option, so we should stop iterating the
|
||||
// possible matching flags. This case shouldn't happen as CLIs don't
|
||||
// generally support adding multiple flags with values in a single position
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
positional.push(
|
||||
ParsedExpression::new(
|
||||
Expression::Unknown,
|
||||
ParsedToken(lite_cmd.parts[idx].item.clone()),
|
||||
)
|
||||
.spanned(lite_cmd.parts[idx].span),
|
||||
);
|
||||
|
||||
error = error.or(err);
|
||||
}
|
||||
} else if !signature.arguments().is_empty()
|
||||
&& signature.arguments().len() > current_positional
|
||||
{
|
||||
let arg = {
|
||||
let arg_signature = &signature.arguments()[current_positional];
|
||||
let (expr, err) = parse_arg(&lite_cmd.parts[idx], Some(arg_signature));
|
||||
|
||||
error = error.or(err);
|
||||
expr
|
||||
};
|
||||
|
||||
positional.push(arg);
|
||||
current_positional += 1;
|
||||
} else if let Some(arg_signature) = signature.arguments().iter().rfind(|a| a.is_variadic())
|
||||
{
|
||||
let (arg, err) = parse_arg(&lite_cmd.parts[idx], Some(arg_signature));
|
||||
error = error.or(err);
|
||||
|
||||
positional.push(arg);
|
||||
current_positional += 1;
|
||||
} else {
|
||||
let expression = if lite_cmd.parts[idx].item.starts_with('$') {
|
||||
parse_dollar_expr(&lite_cmd.parts[idx])
|
||||
} else {
|
||||
ParsedExpression::new(
|
||||
Expression::Unknown,
|
||||
ParsedToken(lite_cmd.parts[idx].item.clone()),
|
||||
)
|
||||
.spanned(lite_cmd.parts[idx].span)
|
||||
};
|
||||
|
||||
positional.push(expression);
|
||||
|
||||
error = error.or_else(|| {
|
||||
Some(ParseError::argument_error(
|
||||
lite_cmd.parts[0].clone(),
|
||||
ArgumentError::UnexpectedArgument(lite_cmd.parts[idx].clone()),
|
||||
))
|
||||
});
|
||||
}
|
||||
|
||||
idx += 1;
|
||||
}
|
||||
|
||||
if let Some(arguments) = &signature.arguments {
|
||||
// Count the required positional arguments and ensure these have been met
|
||||
let mut required_arg_count = 0;
|
||||
for positional_arg in arguments {
|
||||
if positional_arg.optional == IsArgumentOptional::Required {
|
||||
required_arg_count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if positional.len() < required_arg_count {
|
||||
let arg = &arguments[positional.len()];
|
||||
error = error.or_else(|| {
|
||||
Some(ParseError::argument_error(
|
||||
lite_cmd.parts[0].clone(),
|
||||
ArgumentError::MissingMandatoryPositional {
|
||||
name: arg.display_name.clone(),
|
||||
positional_index: positional.len(),
|
||||
},
|
||||
))
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if !named.is_empty() {
|
||||
internal_command.args.flags = Some(named);
|
||||
}
|
||||
|
||||
if !positional.is_empty() {
|
||||
internal_command.args.positionals = Some(positional);
|
||||
}
|
||||
|
||||
(internal_command, error)
|
||||
}
|
||||
|
||||
impl From<&Opt> for FlagArgumentsCardinality {
|
||||
fn from(option: &Opt) -> Self {
|
||||
if option.arguments().iter().any(|arg| arg.is_variadic) {
|
||||
FlagArgumentsCardinality::Variadic
|
||||
} else {
|
||||
FlagArgumentsCardinality::Fixed(
|
||||
option
|
||||
.arguments()
|
||||
.iter()
|
||||
.filter(|arg| arg.is_required())
|
||||
.count(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_flag_signature_spec<'a>(
|
||||
signature: &'a Signature,
|
||||
cmd: &'a ShellCommand,
|
||||
arg: &'a Spanned<String>,
|
||||
) -> (Vec<FlagSignature<'a>>, Option<ParseError>) {
|
||||
// If it's not a flag, don't bother with it.
|
||||
if !arg.item.starts_with('-') {
|
||||
return (vec![], None);
|
||||
}
|
||||
|
||||
let case_insensitive_flags = signature.parser_directives.always_case_insensitive;
|
||||
|
||||
let mut flag = arg.item.as_str();
|
||||
let mut output = vec![];
|
||||
let mut error = None;
|
||||
|
||||
if signature.parser_directives.flags_are_posix_noncompliant || flag.starts_with("--") {
|
||||
if let Some((flag_name, _value)) = flag.split_once('=') {
|
||||
flag = flag_name;
|
||||
}
|
||||
|
||||
if signature.parser_directives.flags_match_unique_prefix {
|
||||
let all_prefix_matches = signature
|
||||
.options()
|
||||
.iter()
|
||||
.filter_map(|opt| {
|
||||
opt.names()
|
||||
.find(|name| {
|
||||
if case_insensitive_flags {
|
||||
name.to_lowercase()
|
||||
.starts_with(flag.to_lowercase().as_str())
|
||||
} else {
|
||||
name.starts_with(flag)
|
||||
}
|
||||
})
|
||||
.map(|name| FlagSignature {
|
||||
name: name.to_owned(),
|
||||
is_switch: opt.is_switch(),
|
||||
arguments_cardinality: FlagArgumentsCardinality::from(opt),
|
||||
arguments: opt.arguments(),
|
||||
})
|
||||
})
|
||||
.exactly_one();
|
||||
match all_prefix_matches {
|
||||
Ok(matched_flag) => output.push(matched_flag),
|
||||
Err(_) => {
|
||||
error = Some(ParseError::argument_error(
|
||||
cmd.name.to_string().spanned(cmd.name_span),
|
||||
ArgumentError::UnexpectedFlag(arg.clone()),
|
||||
));
|
||||
}
|
||||
}
|
||||
} else if let Some(option) = signature.options().iter().find(|option| {
|
||||
if case_insensitive_flags {
|
||||
option
|
||||
.names()
|
||||
.map(str::to_lowercase)
|
||||
.contains(&flag.to_lowercase())
|
||||
} else {
|
||||
option.names().contains(&flag)
|
||||
}
|
||||
}) {
|
||||
output.push(FlagSignature {
|
||||
name: flag.to_owned(),
|
||||
is_switch: option.is_switch(),
|
||||
arguments_cardinality: FlagArgumentsCardinality::from(option),
|
||||
arguments: option.arguments(),
|
||||
});
|
||||
} else {
|
||||
error = Some(ParseError::argument_error(
|
||||
cmd.name.to_string().spanned(cmd.name_span),
|
||||
ArgumentError::UnexpectedFlag(arg.clone()),
|
||||
));
|
||||
}
|
||||
|
||||
// Short flag(s) expected. They might be grouped, e.g. -Alh
|
||||
} else {
|
||||
let mut starting_pos = arg.span.start() + 1;
|
||||
// Loop over each letter as its own option, i.e. instead of "-Alh", process -A, -l, -h, etc.
|
||||
for c in flag.trim_start_matches('-').chars() {
|
||||
let ungrouped_flag = format!("-{c}");
|
||||
|
||||
if let Some(option) = signature.options().iter().find(|option| {
|
||||
if case_insensitive_flags {
|
||||
option
|
||||
.names()
|
||||
.map(str::to_lowercase)
|
||||
.contains(&ungrouped_flag.to_lowercase())
|
||||
} else {
|
||||
option.names().contains(&ungrouped_flag.as_str())
|
||||
}
|
||||
}) {
|
||||
// TODO(alokedesai): Check if we should be using short or long here
|
||||
output.push(FlagSignature {
|
||||
name: ungrouped_flag,
|
||||
is_switch: option.is_switch(),
|
||||
arguments_cardinality: FlagArgumentsCardinality::from(option),
|
||||
arguments: option.arguments(),
|
||||
});
|
||||
} else {
|
||||
error = Some(ParseError::argument_error(
|
||||
cmd.name.to_string().spanned(cmd.name_span),
|
||||
ArgumentError::UnexpectedFlag(
|
||||
arg.item
|
||||
.clone()
|
||||
.spanned(Span::new(starting_pos, starting_pos + c.len_utf8())),
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
starting_pos += c.len_utf8();
|
||||
}
|
||||
}
|
||||
|
||||
(output, error)
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
#[cfg_attr(feature = "v2", path = "v2.rs")]
|
||||
#[cfg_attr(not(feature = "v2"), path = "legacy.rs")]
|
||||
mod imp;
|
||||
use imp::*;
|
||||
|
||||
#[cfg(not(feature = "v2"))]
|
||||
pub use imp::SignatureAtTokenIndex;
|
||||
|
||||
mod errors;
|
||||
pub use errors::{ArgumentError, ParseError, ParseErrorReason};
|
||||
pub mod hir;
|
||||
pub mod simple;
|
||||
|
||||
use derive_new::new;
|
||||
use itertools::Itertools;
|
||||
use lazy_static::lazy_static;
|
||||
use regex::Regex;
|
||||
use warp_command_signatures::Argument;
|
||||
|
||||
use crate::signatures::CommandRegistry;
|
||||
use crate::{
|
||||
completer::TopLevelCommandCaseSensitivity,
|
||||
meta::{HasSpan, Span, Spanned, SpannedItem},
|
||||
};
|
||||
|
||||
use hir::{ArgType, Command, Expression, ExternalCommand};
|
||||
|
||||
lazy_static! {
|
||||
// Regex to test for a valid environment variable name, Environment variable names used by the
|
||||
// utilities in the Shell and Utilities volume of IEEE Std 1003.1-2001 consist solely of
|
||||
// upper or lowercase letters, digits, and the '_' (underscore) from the characters defined in
|
||||
// Portable Character Set and do not begin with a digit.
|
||||
static ref ENV_VAR_NAME_REGEX: Regex = Regex::new("^[$][a-zA-Z_][a-zA-Z0-9_]*$").unwrap();
|
||||
}
|
||||
|
||||
type SpannedKeyValue = (Spanned<String>, Spanned<String>);
|
||||
|
||||
/// A `LitePipeline` is a series of `LiteCommand`s separated by `|`.
|
||||
#[derive(Debug, Clone, new)]
|
||||
pub struct LitePipeline {
|
||||
pub commands: Vec<LiteCommand>,
|
||||
}
|
||||
|
||||
impl HasSpan for LitePipeline {
|
||||
fn span(&self) -> Span {
|
||||
Span::from_list(&self.commands)
|
||||
}
|
||||
}
|
||||
|
||||
/// A `LiteCommand` is a list of words that will get meaning when processed by
|
||||
/// the parser.
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct LiteCommand {
|
||||
pub parts: Vec<Spanned<String>>,
|
||||
/// The pieces of the command that ended in a whitespace.
|
||||
pub post_whitespace: Option<Span>,
|
||||
}
|
||||
|
||||
impl LiteCommand {
|
||||
/// Returns `parts` joined together by single spaces.
|
||||
pub fn joined_by_space(&self) -> String {
|
||||
self.parts.iter().map(|s| s.as_str()).join(" ")
|
||||
}
|
||||
}
|
||||
|
||||
impl HasSpan for LiteCommand {
|
||||
fn span(&self) -> Span {
|
||||
let span = Span::from_list(&self.parts);
|
||||
self.post_whitespace
|
||||
.as_ref()
|
||||
.map(|whitespace| span.until(whitespace))
|
||||
.unwrap_or(span)
|
||||
}
|
||||
}
|
||||
|
||||
/// A `LiteGroup` is a series of `LitePipeline`s, separated by `;`.
|
||||
#[derive(Debug, Clone, new)]
|
||||
pub struct LiteGroup {
|
||||
pub pipelines: Vec<LitePipeline>,
|
||||
}
|
||||
|
||||
impl HasSpan for LiteGroup {
|
||||
fn span(&self) -> Span {
|
||||
Span::from_list(&self.pipelines)
|
||||
}
|
||||
}
|
||||
|
||||
/// A `LiteRootNode` is the root node of the parsed AST. Essentially a series of `LiteGroup`s,
|
||||
/// separated by newlines.
|
||||
#[derive(Debug, Clone, new)]
|
||||
pub struct LiteRootNode {
|
||||
pub groups: Vec<LiteGroup>,
|
||||
}
|
||||
|
||||
impl HasSpan for LiteRootNode {
|
||||
fn span(&self) -> Span {
|
||||
Span::from_list(&self.groups)
|
||||
}
|
||||
}
|
||||
|
||||
/// A command classified by its arguments, flags, etc. Optionally includes an error if there was
|
||||
/// a parse error while trying to classify the command.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ClassifiedCommand {
|
||||
pub command: Command,
|
||||
/// Environment variables specified on the command line.
|
||||
/// Each entry looks like "KEY=VALUE"
|
||||
pub env_vars: Vec<String>,
|
||||
pub error: Option<ParseError>,
|
||||
}
|
||||
|
||||
/// Converts a `LiteCommand` into a `Command` that is annotated with its positional arguments,
|
||||
/// flags, etc based on corresponding completion specs.
|
||||
/// Modifies `tokens` to remove any environment variables.
|
||||
/// Returns none if the command is unable to
|
||||
/// be classified.
|
||||
pub fn classify_command(
|
||||
lite_command: LiteCommand,
|
||||
tokens: &mut Vec<&str>,
|
||||
command_registry: &CommandRegistry,
|
||||
command_case_sensitivity: TopLevelCommandCaseSensitivity,
|
||||
) -> Option<ClassifiedCommand> {
|
||||
let mut error = None;
|
||||
|
||||
// TODO: potentially change to mutable reference (i.e. expand_shorthand_forms
|
||||
// directly mutates lite_command)?
|
||||
let (lite_command, vars, err) = expand_shorthand_forms(lite_command);
|
||||
if error.is_none() {
|
||||
error = err;
|
||||
}
|
||||
// Note that the caller of expand_shorthand_forms is responsible for updating
|
||||
// their version of tokens, given the output of variables in environment
|
||||
// variable assignments.
|
||||
let env_vars = tokens
|
||||
.drain(0..vars.len())
|
||||
.map(|s| s.to_string())
|
||||
.collect_vec();
|
||||
|
||||
let (command, err) = parse_command(
|
||||
&lite_command,
|
||||
tokens,
|
||||
command_registry,
|
||||
command_case_sensitivity,
|
||||
);
|
||||
|
||||
if error.is_none() {
|
||||
error = err;
|
||||
}
|
||||
|
||||
command.map(|command| ClassifiedCommand {
|
||||
command,
|
||||
env_vars,
|
||||
error,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_arg(
|
||||
lite_arg: &Spanned<String>,
|
||||
arg_signature: Option<&Argument>,
|
||||
) -> (Spanned<ParsedExpression>, Option<ParseError>) {
|
||||
if lite_arg.item == "$" || ENV_VAR_NAME_REGEX.is_match(lite_arg.item.as_str()) {
|
||||
return (parse_dollar_expr(lite_arg), None);
|
||||
}
|
||||
let arg_types_to_validate =
|
||||
arg_signature.map(ArgType::get_arg_types_to_validate_from_arg_signature);
|
||||
if let Some(arg_types_to_validate) = arg_types_to_validate {
|
||||
if !arg_types_to_validate.is_empty() {
|
||||
return (
|
||||
ParsedExpression::new(
|
||||
Expression::ValidatableArgument(arg_types_to_validate),
|
||||
ParsedToken(lite_arg.item.clone()),
|
||||
)
|
||||
.spanned(lite_arg.span),
|
||||
None,
|
||||
);
|
||||
}
|
||||
}
|
||||
(
|
||||
ParsedExpression::new(Expression::Literal, ParsedToken(lite_arg.item.clone()))
|
||||
.spanned(lite_arg.span),
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
fn trim_quotes(input: &str) -> String {
|
||||
let mut chars = input.chars();
|
||||
|
||||
match (chars.next(), chars.next_back()) {
|
||||
(Some('\''), Some('\'')) => chars.collect(),
|
||||
(Some('"'), Some('"')) => chars.collect(),
|
||||
(Some('`'), Some('`')) => chars.collect(),
|
||||
_ => input.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Given a lite command and its tokens, remove the environment variable assignment token
|
||||
/// from it for completion generation. Note that the caller should mutate their tokens
|
||||
/// according to the vector of variables returned (remove them from the start)!
|
||||
///
|
||||
/// TODO: consolidate with [`Command::remove_leading_env_vars`].
|
||||
fn expand_shorthand_forms(
|
||||
mut lite_command: LiteCommand,
|
||||
) -> (LiteCommand, Vec<SpannedKeyValue>, Option<ParseError>) {
|
||||
let mut vars = Vec::new();
|
||||
while !lite_command.parts.is_empty() {
|
||||
let first_command_part = match lite_command.parts.first() {
|
||||
Some(part) if part.item != "=" && part.contains('=') => part.clone(),
|
||||
_ => return (lite_command, vars, None),
|
||||
};
|
||||
|
||||
let assignment: Vec<_> = first_command_part.split('=').collect();
|
||||
if assignment.len() != 2 {
|
||||
return (
|
||||
lite_command,
|
||||
vars,
|
||||
Some(ParseError::mismatch(
|
||||
"environment variable assignment",
|
||||
first_command_part.clone(),
|
||||
)),
|
||||
);
|
||||
} else {
|
||||
let original_span = first_command_part.span;
|
||||
let (variable_name, value) = (assignment[0], trim_quotes(assignment[1]));
|
||||
|
||||
lite_command.parts.remove(0);
|
||||
vars.push((
|
||||
variable_name.to_string().spanned(original_span),
|
||||
value.spanned(original_span),
|
||||
));
|
||||
}
|
||||
}
|
||||
(lite_command, vars, None)
|
||||
}
|
||||
|
||||
/// Parses a command that is not in the command registry.
|
||||
fn parse_unclassified_command(lite_cmd: &LiteCommand) -> (Command, Option<ParseError>) {
|
||||
let mut error = None;
|
||||
|
||||
let external_name = lite_cmd.parts[0].clone().map(|v| trim_quotes(&v));
|
||||
|
||||
let mut external_command = ExternalCommand::new(
|
||||
ParsedToken(external_name.item),
|
||||
external_name.span,
|
||||
lite_cmd.span(),
|
||||
);
|
||||
external_command.args.ending_whitespace = lite_cmd.post_whitespace;
|
||||
|
||||
let num_parts = lite_cmd.parts.len() - 1;
|
||||
let mut args = Vec::with_capacity(num_parts);
|
||||
|
||||
if lite_cmd.parts.len() > 1 {
|
||||
for lite_arg in &lite_cmd.parts[1..] {
|
||||
let (expr, err) = parse_arg(lite_arg, None);
|
||||
if error.is_none() {
|
||||
error = err;
|
||||
}
|
||||
args.push(expr);
|
||||
}
|
||||
|
||||
external_command.args.positionals = Some(args);
|
||||
}
|
||||
|
||||
(Command::Unclassified(external_command), error)
|
||||
}
|
||||
|
||||
/// The number of arguments for an option.
|
||||
#[derive(Clone, Debug)]
|
||||
enum FlagArgumentsCardinality {
|
||||
/// The option has exactly k required args.
|
||||
Fixed(usize),
|
||||
/// The option has some arg that is variadic
|
||||
/// (it might also have some number of non-variadic args).
|
||||
Variadic,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct FlagSignature<'a> {
|
||||
name: String,
|
||||
is_switch: bool,
|
||||
arguments_cardinality: FlagArgumentsCardinality,
|
||||
arguments: &'a [Argument],
|
||||
}
|
||||
|
||||
fn parse_dollar_expr(lite_arg: &Spanned<String>) -> Spanned<ParsedExpression> {
|
||||
ParsedExpression::new(Expression::Variable, ParsedToken(lite_arg.item.clone()))
|
||||
.spanned(lite_arg.span)
|
||||
}
|
||||
|
||||
/// Newtype denoting a token that has been parsed by the parser. For example, `foo\ bar` would have
|
||||
/// would result in a parsed token value of `foo bar`.
|
||||
#[derive(Debug, Clone, Eq, PartialEq)]
|
||||
pub struct ParsedToken(String);
|
||||
|
||||
impl ParsedToken {
|
||||
#[cfg(feature = "test-util")]
|
||||
pub fn new(value: impl Into<String>) -> Self {
|
||||
Self(value.into())
|
||||
}
|
||||
|
||||
pub fn empty() -> Self {
|
||||
Self("".into())
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
self.0.as_str()
|
||||
}
|
||||
}
|
||||
|
||||
/// An expression with the parsed value that the expression corresponds to.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ParsedExpression {
|
||||
expression: Expression,
|
||||
parsed_token: ParsedToken,
|
||||
}
|
||||
|
||||
impl ParsedExpression {
|
||||
pub fn new(expression: Expression, parsed_token: ParsedToken) -> Self {
|
||||
Self {
|
||||
expression,
|
||||
parsed_token,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn expression(&self) -> &Expression {
|
||||
&self.expression
|
||||
}
|
||||
|
||||
pub fn value(&self) -> &ParsedToken {
|
||||
&self.parsed_token
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test;
|
||||
@@ -0,0 +1,57 @@
|
||||
use super::{Command, Part};
|
||||
use crate::meta::{Span, Spanned};
|
||||
use crate::parsers::{LiteCommand, LiteGroup, LitePipeline, LiteRootNode};
|
||||
use std::fmt;
|
||||
|
||||
impl From<Spanned<Command>> for LiteCommand {
|
||||
fn from(command: Spanned<Command>) -> Self {
|
||||
let post_whitespace = command.item.parts.last().and_then(|part| {
|
||||
if part.span.end() < command.span.end() {
|
||||
Some(Span::new(part.span.end(), command.span.end()))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
LiteCommand {
|
||||
parts: command.item.parts.into_iter().map(Into::into).collect(),
|
||||
post_whitespace,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Spanned<Part>> for Spanned<String> {
|
||||
fn from(spanned: Spanned<Part>) -> Spanned<String> {
|
||||
spanned.map(|part| part.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Option<LiteCommand>> for LiteRootNode {
|
||||
fn from(command: Option<LiteCommand>) -> Self {
|
||||
LiteRootNode {
|
||||
groups: vec![LiteGroup {
|
||||
pipelines: vec![LitePipeline {
|
||||
commands: command.into_iter().collect(),
|
||||
}],
|
||||
}],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Part {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Part::Literal(value) => f.write_str(value.as_str()),
|
||||
Part::OpenSubshell(_) | Part::ClosedSubshell(_) => {
|
||||
// Since we aren't evaluating the subshell, include a placeholder value
|
||||
f.write_str("$(...)")
|
||||
}
|
||||
Part::Concatenated(parts) => {
|
||||
for part in parts {
|
||||
part.item.fmt(f)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
use std::iter::Peekable;
|
||||
|
||||
use super::token::Token;
|
||||
use crate::meta::Spanned;
|
||||
|
||||
/// Adapter for tracking the parser's current position in the input and allowing for double peek
|
||||
pub struct ParserInput<I>
|
||||
where
|
||||
I: IntoIterator,
|
||||
{
|
||||
iter: Peekable<I::IntoIter>,
|
||||
peeked: Option<I::Item>,
|
||||
pos: usize,
|
||||
offset: usize,
|
||||
}
|
||||
|
||||
impl<'a, I> ParserInput<I>
|
||||
where
|
||||
I: IntoIterator<Item = Spanned<Token<'a>>>,
|
||||
{
|
||||
pub fn new(input: I) -> Self {
|
||||
let iter = input.into_iter().peekable();
|
||||
ParserInput {
|
||||
iter,
|
||||
peeked: None,
|
||||
pos: 0,
|
||||
offset: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the next token from the input
|
||||
pub fn next(&mut self) -> Option<Token<'a>> {
|
||||
let next = self.peeked.take().or_else(|| self.iter.next());
|
||||
|
||||
match next {
|
||||
Some(token) => {
|
||||
self.pos = self.offset + token.span.end();
|
||||
Some(token.item)
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the next token from the input, including the span
|
||||
fn next_with_span(&mut self) -> Option<Spanned<Token<'a>>> {
|
||||
let next = self.peeked.take().or_else(|| self.iter.next());
|
||||
|
||||
if let Some(token) = &next {
|
||||
self.pos = self.offset + token.span.end();
|
||||
}
|
||||
|
||||
next
|
||||
}
|
||||
|
||||
/// Get a reference to the next token without consuming it
|
||||
pub fn peek(&mut self) -> Option<&Token<'a>> {
|
||||
match &self.peeked {
|
||||
Some(value) => Some(&value.item),
|
||||
None => self.iter.peek().map(|t| &t.item),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a reference to the token after the next, without consuming either
|
||||
pub fn peekpeek(&mut self) -> Option<&Token<'a>> {
|
||||
if self.peeked.is_none() {
|
||||
self.peeked = self.iter.next();
|
||||
}
|
||||
|
||||
self.iter.peek().map(|t| &t.item)
|
||||
}
|
||||
|
||||
/// Get the position where we are currently, immediately after the last element that was read
|
||||
pub fn pos(&self) -> usize {
|
||||
self.pos
|
||||
}
|
||||
|
||||
/// Create a ParserInput that will yield tokens until a backtick is found and then stop,
|
||||
/// preserving the position.
|
||||
///
|
||||
/// Note: This will buffer all of the tokens until the next backtick
|
||||
pub fn until_backtick(&mut self) -> ParserInput<Vec<Spanned<Token<'a>>>> {
|
||||
let mut buffer = Vec::new();
|
||||
let start = self.pos;
|
||||
|
||||
while let Some(token) = self.peek() {
|
||||
match token {
|
||||
Token::Backtick => break,
|
||||
_ => {
|
||||
// Safety: We are peeking first so the next token will always exist
|
||||
buffer.push(self.next_with_span().unwrap());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ParserInput {
|
||||
iter: buffer.into_iter().peekable(),
|
||||
peeked: None,
|
||||
pos: start,
|
||||
offset: self.offset,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
use super::{token::Token, EscapeChar};
|
||||
use crate::meta::{Span, Spanned, SpannedItem};
|
||||
|
||||
/// Iterator for converting a string into a series of Tokens
|
||||
///
|
||||
/// This step is very naive, it does not attempt to understand the various contexts in which tokens
|
||||
/// can appear (e.g. within double or single quotes, within subshells nested within double quotes).
|
||||
/// The parser will track all of the necessary state in order to properly interpret each token
|
||||
/// given the context.
|
||||
pub struct Lexer<'a> {
|
||||
/// The source string that we are tokenizing
|
||||
source: &'a str,
|
||||
escape_char: EscapeChar,
|
||||
/// The current byte index in the source
|
||||
pos: usize,
|
||||
/// Processed character that we have seen but not yet yielded
|
||||
queued: Option<Classified<'a>>,
|
||||
/// Whether to consider quotes as literals in the lexer.
|
||||
parse_quotes_as_literals: bool,
|
||||
}
|
||||
|
||||
/// The classification of a character into a known Token, Raw character value (for Literal tokens)
|
||||
/// or an Escaped token (via `\`). This intermediate classification helps to process Literal and
|
||||
/// Escaped tokens properly without needing to handle the specific parser context.
|
||||
enum Classified<'a> {
|
||||
Token(Spanned<Token<'a>>),
|
||||
Raw(Spanned<char>),
|
||||
Escaped {
|
||||
escape_char: Spanned<Token<'a>>,
|
||||
next_token: Option<Spanned<Token<'a>>>,
|
||||
},
|
||||
}
|
||||
|
||||
impl From<Spanned<char>> for Classified<'_> {
|
||||
fn from(chr: Spanned<char>) -> Self {
|
||||
Classified::Raw(chr)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> From<Spanned<Token<'a>>> for Classified<'a> {
|
||||
fn from(tok: Spanned<Token<'a>>) -> Self {
|
||||
Classified::Token(tok)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Lexer<'a> {
|
||||
pub fn new(source: &'a str, escape_char: EscapeChar, parse_quotes_as_literals: bool) -> Self {
|
||||
Lexer {
|
||||
source,
|
||||
escape_char,
|
||||
pos: 0,
|
||||
queued: None,
|
||||
parse_quotes_as_literals,
|
||||
}
|
||||
}
|
||||
|
||||
/// Advance the underlying iterator one step, returning the next character and its span
|
||||
fn step(&mut self) -> Option<(Span, char)> {
|
||||
let (span, chr) = self.peek()?;
|
||||
|
||||
self.pos = span.end();
|
||||
Some((span, chr))
|
||||
}
|
||||
|
||||
/// Peek at the next character without consuming it, returning the span as well
|
||||
fn peek(&mut self) -> Option<(Span, char)> {
|
||||
let start = self.pos;
|
||||
let chr = self.source[start..].chars().next()?;
|
||||
let end = start + chr.len_utf8();
|
||||
|
||||
let span = Span::new(start, end);
|
||||
Some((span, chr))
|
||||
}
|
||||
|
||||
/// Classify the next character as either a token, escaped token, or raw value.
|
||||
///
|
||||
/// Note that some tokens will consume multiple characters.
|
||||
fn classify_next(&mut self) -> Option<Classified<'a>> {
|
||||
if self.queued.is_some() {
|
||||
return self.queued.take();
|
||||
}
|
||||
|
||||
let (span, chr) = self.step()?;
|
||||
|
||||
Some(match chr {
|
||||
'|' => {
|
||||
if let Some((next, '|')) = self.peek() {
|
||||
self.next();
|
||||
Token::LogicalOr.spanned(span.until(next)).into()
|
||||
} else {
|
||||
Token::Pipe.spanned(span).into()
|
||||
}
|
||||
}
|
||||
'&' => {
|
||||
if let Some((next, '&')) = self.peek() {
|
||||
self.next();
|
||||
Token::LogicalAnd.spanned(span.until(next)).into()
|
||||
} else {
|
||||
Token::Ampersand.spanned(span).into()
|
||||
}
|
||||
}
|
||||
';' => Token::Semicolon.spanned(span).into(),
|
||||
'\n' => Token::Newline.spanned(span).into(),
|
||||
'(' => Token::OpenParen.spanned(span).into(),
|
||||
')' => Token::CloseParen.spanned(span).into(),
|
||||
'{' => Token::OpenCurly.spanned(span).into(),
|
||||
'}' => Token::CloseCurly.spanned(span).into(),
|
||||
'$' => Token::Dollar.spanned(span).into(),
|
||||
'\'' if !self.parse_quotes_as_literals => Token::SingleQuote.spanned(span).into(),
|
||||
'"' if !self.parse_quotes_as_literals => Token::DoubleQuote.spanned(span).into(),
|
||||
'<' => Token::RedirectInput.spanned(span).into(),
|
||||
'>' => Token::RedirectOutput.spanned(span).into(),
|
||||
'\\' | '`' if self.escape_char.is_char(chr) => {
|
||||
// Consume the following character as a token by itself
|
||||
// Note: Since the internal Lexer uses spans relative to its slice, so we need
|
||||
// to adjust them to match our positions
|
||||
let pos_adjust = self.pos;
|
||||
let next_token = self
|
||||
.step()
|
||||
.and_then(|(span, _)| {
|
||||
Lexer::new(
|
||||
span.slice(self.source),
|
||||
self.escape_char,
|
||||
self.parse_quotes_as_literals,
|
||||
)
|
||||
.next()
|
||||
})
|
||||
.map(|token| {
|
||||
let new_start = token.span.start() + pos_adjust;
|
||||
let new_end = token.span.end() + pos_adjust;
|
||||
|
||||
token.item.spanned(Span::new(new_start, new_end))
|
||||
});
|
||||
Classified::Escaped {
|
||||
escape_char: Token::EscapeChar(span.slice(self.source)).spanned(span),
|
||||
next_token,
|
||||
}
|
||||
}
|
||||
'`' => Token::Backtick.spanned(span).into(),
|
||||
c if c.is_whitespace() => {
|
||||
// Note: We handle newlines earlier in the match, so this excludes newlines
|
||||
let mut span = span;
|
||||
|
||||
while let Some((next_span, next_chr)) = self.peek() {
|
||||
if next_chr.is_whitespace() && next_chr != '\n' {
|
||||
span = span.until(next_span);
|
||||
self.step();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Token::Whitespace(span.slice(self.source))
|
||||
.spanned(span)
|
||||
.into()
|
||||
}
|
||||
c => c.spanned(span).into(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Iterator for Lexer<'a> {
|
||||
type Item = Spanned<Token<'a>>;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
match self.classify_next() {
|
||||
None => None,
|
||||
Some(Classified::Token(t)) => Some(t),
|
||||
Some(Classified::Escaped {
|
||||
escape_char,
|
||||
next_token,
|
||||
}) => {
|
||||
// Note: For escaped values, we still need to include a backslash token, because
|
||||
// there are some contexts (e.g. within a single-quoted value) where the backslash
|
||||
// can be interpreted as a literal value. It's left up to the parser to determine
|
||||
// how to handle the backslash.
|
||||
self.queued = next_token.map(Into::into);
|
||||
Some(escape_char)
|
||||
}
|
||||
Some(Classified::Raw(chr)) => {
|
||||
let mut span = chr.span;
|
||||
|
||||
loop {
|
||||
match self.classify_next() {
|
||||
Some(Classified::Raw(chr)) => {
|
||||
span = span.until(chr.span);
|
||||
}
|
||||
other => {
|
||||
self.queued = other;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let value = span.slice(self.source);
|
||||
Some(Token::Literal(value).spanned(span))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn size_hint(&self) -> (usize, Option<usize>) {
|
||||
let lower = match &self.queued {
|
||||
Some(_) => 1,
|
||||
None => 0,
|
||||
};
|
||||
let upper = self.source.len() - self.pos;
|
||||
|
||||
if upper < lower {
|
||||
(lower, None)
|
||||
} else {
|
||||
(lower, Some(upper))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "lexer_test.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,218 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_lexer() {
|
||||
let source = r#"ls | rm -rf || touch 'hello.txt' &
|
||||
cat "Hello $(ls -la)" && echo `ps \`; {echo Goodbye😀}"#;
|
||||
let tokens: Vec<_> = Lexer::new(source, EscapeChar::Backslash, false)
|
||||
.map(|t| t.item)
|
||||
.collect();
|
||||
|
||||
assert_eq!(
|
||||
tokens,
|
||||
[
|
||||
Token::Literal("ls"),
|
||||
Token::Whitespace(" "),
|
||||
Token::Pipe,
|
||||
Token::Whitespace(" "),
|
||||
Token::Literal("rm"),
|
||||
Token::Whitespace(" "),
|
||||
Token::Literal("-rf"),
|
||||
Token::Whitespace(" "),
|
||||
Token::LogicalOr,
|
||||
Token::Whitespace(" "),
|
||||
Token::Literal("touch"),
|
||||
Token::Whitespace(" "),
|
||||
Token::SingleQuote,
|
||||
Token::Literal("hello.txt"),
|
||||
Token::SingleQuote,
|
||||
Token::Whitespace(" "),
|
||||
Token::Ampersand,
|
||||
Token::Newline,
|
||||
Token::Literal("cat"),
|
||||
Token::Whitespace(" "),
|
||||
Token::DoubleQuote,
|
||||
Token::Literal("Hello"),
|
||||
Token::Whitespace(" "),
|
||||
Token::Dollar,
|
||||
Token::OpenParen,
|
||||
Token::Literal("ls"),
|
||||
Token::Whitespace(" "),
|
||||
Token::Literal("-la"),
|
||||
Token::CloseParen,
|
||||
Token::DoubleQuote,
|
||||
Token::Whitespace(" "),
|
||||
Token::LogicalAnd,
|
||||
Token::Whitespace(" "),
|
||||
Token::Literal("echo"),
|
||||
Token::Whitespace(" "),
|
||||
Token::Backtick,
|
||||
Token::Literal("ps"),
|
||||
Token::Whitespace(" "),
|
||||
Token::EscapeChar("\\"),
|
||||
Token::Backtick,
|
||||
Token::Semicolon,
|
||||
Token::Whitespace(" "),
|
||||
Token::OpenCurly,
|
||||
Token::Literal("echo"),
|
||||
Token::Whitespace(" "),
|
||||
Token::Literal("Goodbye😀"),
|
||||
Token::CloseCurly,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_spans() {
|
||||
let source = "ls -la && echo Hello' World'$(cat 😀.txt";
|
||||
let spans: Vec<_> = Lexer::new(source, EscapeChar::Backslash, false)
|
||||
.map(|t| t.span)
|
||||
.collect();
|
||||
|
||||
assert_eq!(
|
||||
spans,
|
||||
[
|
||||
Span::new(0, 2), // ls
|
||||
Span::new(2, 3), // space
|
||||
Span::new(3, 6), // -la
|
||||
Span::new(6, 7), // space
|
||||
Span::new(7, 9), // &&
|
||||
Span::new(9, 10), // space
|
||||
Span::new(10, 14), // echo
|
||||
Span::new(14, 15), // space
|
||||
Span::new(15, 20), // Hello
|
||||
Span::new(20, 21), // '
|
||||
Span::new(21, 22), // space
|
||||
Span::new(22, 27), // World
|
||||
Span::new(27, 28), // '
|
||||
Span::new(28, 29), // $
|
||||
Span::new(29, 30), // (
|
||||
Span::new(30, 33), // cat
|
||||
Span::new(33, 35), // double space
|
||||
Span::new(35, 43), // 😀.txt (😀 is 4 bytes long)
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_escaped_tokens() {
|
||||
let source = r"\\\||\&&\\&&||";
|
||||
let tokens: Vec<_> = Lexer::new(source, EscapeChar::Backslash, false)
|
||||
.map(|t| t.item)
|
||||
.collect();
|
||||
|
||||
assert_eq!(
|
||||
tokens,
|
||||
[
|
||||
Token::EscapeChar("\\"),
|
||||
Token::EscapeChar("\\"),
|
||||
Token::EscapeChar("\\"),
|
||||
Token::Pipe,
|
||||
Token::Pipe,
|
||||
Token::EscapeChar("\\"),
|
||||
Token::Ampersand,
|
||||
Token::Ampersand,
|
||||
Token::EscapeChar("\\"),
|
||||
Token::EscapeChar("\\"),
|
||||
Token::LogicalAnd,
|
||||
Token::LogicalOr,
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_escaped_token_spans() {
|
||||
let source = r"\\\||\&&\\&&||";
|
||||
let spans: Vec<_> = Lexer::new(source, EscapeChar::Backslash, false)
|
||||
.map(|t| t.span)
|
||||
.collect();
|
||||
|
||||
assert_eq!(
|
||||
spans,
|
||||
[
|
||||
Span::new(0, 1), // \
|
||||
Span::new(1, 2), // \
|
||||
Span::new(2, 3), // \
|
||||
Span::new(3, 4), // |
|
||||
Span::new(4, 5), // |
|
||||
Span::new(5, 6), // \
|
||||
Span::new(6, 7), // &
|
||||
Span::new(7, 8), // &
|
||||
Span::new(8, 9), // \
|
||||
Span::new(9, 10), // \
|
||||
Span::new(10, 12), // &&
|
||||
Span::new(12, 14), // ||
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multiple_whitespace() {
|
||||
let source = " \t |\t ";
|
||||
let tokens: Vec<_> = Lexer::new(source, EscapeChar::Backslash, false)
|
||||
.map(|t| t.item)
|
||||
.collect();
|
||||
|
||||
assert_eq!(
|
||||
tokens,
|
||||
[
|
||||
Token::Whitespace(" \t "),
|
||||
Token::Pipe,
|
||||
Token::Whitespace("\t "),
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_backtick_escape_char() {
|
||||
let source = r#"& "$HOME\Downloads\Warp` Setup.exe" /SP- /SILENT `t`"#;
|
||||
let tokens: Vec<_> = Lexer::new(source, EscapeChar::Backtick, false)
|
||||
.map(|t| (t.item, t.span))
|
||||
.collect();
|
||||
|
||||
assert_eq!(
|
||||
tokens,
|
||||
[
|
||||
(Token::Ampersand, Span::new(0, 1)),
|
||||
(Token::Whitespace(" "), Span::new(1, 2)),
|
||||
(Token::DoubleQuote, Span::new(2, 3)),
|
||||
(Token::Dollar, Span::new(3, 4)),
|
||||
(Token::Literal(r"HOME\Downloads\Warp"), Span::new(4, 23)),
|
||||
(Token::EscapeChar("`"), Span::new(23, 24)),
|
||||
(Token::Whitespace(" "), Span::new(24, 25)),
|
||||
(Token::Literal("Setup.exe"), Span::new(25, 34)),
|
||||
(Token::DoubleQuote, Span::new(34, 35)),
|
||||
(Token::Whitespace(" "), Span::new(35, 36)),
|
||||
(Token::Literal("/SP-"), Span::new(36, 40)),
|
||||
(Token::Whitespace(" "), Span::new(40, 41)),
|
||||
(Token::Literal("/SILENT"), Span::new(41, 48)),
|
||||
(Token::Whitespace(" "), Span::new(48, 49)),
|
||||
(Token::EscapeChar("`"), Span::new(49, 50)),
|
||||
(Token::Literal("t"), Span::new(50, 51)),
|
||||
(Token::EscapeChar("`"), Span::new(51, 52)),
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_single_quote_as_literals() {
|
||||
let source = r#"I'd like to edit app/src"#;
|
||||
let tokens: Vec<_> = Lexer::new(source, EscapeChar::Backslash, true)
|
||||
.map(|t| (t.item, t.span))
|
||||
.collect();
|
||||
|
||||
assert_eq!(
|
||||
tokens,
|
||||
[
|
||||
(Token::Literal("I'd"), Span::new(0, 3)),
|
||||
(Token::Whitespace(" "), Span::new(3, 4)),
|
||||
(Token::Literal("like"), Span::new(4, 8)),
|
||||
(Token::Whitespace(" "), Span::new(8, 9)),
|
||||
(Token::Literal("to"), Span::new(9, 11)),
|
||||
(Token::Whitespace(" "), Span::new(11, 12)),
|
||||
(Token::Literal("edit"), Span::new(12, 16)),
|
||||
(Token::Whitespace(" "), Span::new(16, 17)),
|
||||
(Token::Literal("app/src"), Span::new(17, 24)),
|
||||
]
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
use crate::meta::{Spanned, SpannedItem};
|
||||
|
||||
mod convert;
|
||||
mod iter;
|
||||
mod lexer;
|
||||
mod parser;
|
||||
mod token;
|
||||
|
||||
use crate::parsers::LiteCommand;
|
||||
use lexer::Lexer;
|
||||
use parser::Parser;
|
||||
use galaxy_util::path::EscapeChar;
|
||||
|
||||
use string_offset::ByteOffset;
|
||||
|
||||
/// Parse the input and return the last unclosed command to complete on using the completions
|
||||
/// infrastructure.
|
||||
///
|
||||
/// To make sure we are completing the correct thing, this will pull off the last unclosed command
|
||||
/// from the parsed input
|
||||
pub fn parse_for_completions<S: AsRef<str>>(
|
||||
source: S,
|
||||
escape_char: EscapeChar,
|
||||
parse_quotes_as_literals: bool,
|
||||
) -> Option<LiteCommand> {
|
||||
let parser = Parser::new(Lexer::new(
|
||||
source.as_ref(),
|
||||
escape_char,
|
||||
parse_quotes_as_literals,
|
||||
));
|
||||
let commands = parser.parse().commands;
|
||||
commands
|
||||
.into_iter()
|
||||
.next_back()
|
||||
.map(last_unclosed_command)
|
||||
.map(Into::into)
|
||||
}
|
||||
|
||||
/// Returns the name of the top-level command in `source`.
|
||||
///
|
||||
/// For example, if the source is "PAGER=0 git log", the top-level command is "git".
|
||||
/// If there are multiple top-level commands (e.g. `ls && git diff`), the first
|
||||
/// one is returned.
|
||||
pub fn top_level_command<S: AsRef<str>>(source: S, escape_char: EscapeChar) -> Option<String> {
|
||||
let parser = Parser::new(Lexer::new(source.as_ref(), escape_char, false));
|
||||
let mut command = parser.parse().commands.into_iter().next()?;
|
||||
command.item.remove_leading_env_vars();
|
||||
|
||||
match command
|
||||
.parts
|
||||
.first()
|
||||
.map(|p: &Spanned<Part>| p.item.clone())
|
||||
{
|
||||
Some(Part::Literal(p)) => Some(p),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses the command to retrieve the command at the given cursor pos.
|
||||
/// This is needed for command x-ray since we need to run the completion engine on tokens
|
||||
/// that might be in the middle of a command or subcommand.
|
||||
///
|
||||
/// For example, if our command was `cd ~/Desktop $(cd ~/foo)` and our cursor
|
||||
/// was at `/foo` then we want the subcommand `cd ~/foo` instead of the parent command.
|
||||
pub fn command_at_cursor_position<S: AsRef<str>>(
|
||||
source: S,
|
||||
escape_char: EscapeChar,
|
||||
pos: ByteOffset,
|
||||
) -> Option<LiteCommand> {
|
||||
let parser = Parser::new(Lexer::new(source.as_ref(), escape_char, false));
|
||||
let commands = parser.parse().commands;
|
||||
command_at_cursor(commands, pos).map(Into::into)
|
||||
}
|
||||
|
||||
/// Parses the string to retrieve all commands (separated into iterator items).
|
||||
/// This is needed for error underlining since we need to determine whether each
|
||||
/// command is valid or not.
|
||||
///
|
||||
/// For example, if our command was `git commit && git log`, we would return
|
||||
/// an iterator of 2 items - the parsed commands for "git commit" and "git log".
|
||||
pub fn all_parsed_commands<S: AsRef<str>>(
|
||||
source: S,
|
||||
escape_char: EscapeChar,
|
||||
) -> impl Iterator<Item = LiteCommand> {
|
||||
let parser = Parser::new(Lexer::new(source.as_ref(), escape_char, false));
|
||||
parser.parse().commands.into_iter().map(|mut cmd| {
|
||||
cmd.item.remove_leading_env_vars();
|
||||
cmd.into()
|
||||
})
|
||||
}
|
||||
|
||||
/// Given a `command` string, returns:
|
||||
/// 1. the subcommands that make it up, including the recomposed commands at each level of nesting.
|
||||
/// For example, given "ls $(foo | echo)", this API returns ["foo", "echo", "foo | echo", "ls $(foo | echo)"]
|
||||
/// 2. whether or not the `command` included any redirection operators (i.e. '>' or '<')
|
||||
pub fn decompose_command(command: &str, escape_char: EscapeChar) -> (Vec<String>, bool) {
|
||||
let parser = Parser::new(Lexer::new(command, escape_char, false));
|
||||
let res = parser.parse();
|
||||
let (commands, contains_redirection) = (res.commands, res.contains_redirection);
|
||||
|
||||
(
|
||||
commands
|
||||
.into_iter()
|
||||
.flat_map(|cmd| cmd.item.decompose(command))
|
||||
.collect(),
|
||||
contains_redirection,
|
||||
)
|
||||
}
|
||||
|
||||
/// Retrieves the smallest complete command at a given pos.
|
||||
///
|
||||
/// Ex: For the dummy command "git status $(git stash) && git checkout main" -
|
||||
/// pos 11 would return the subcommand corresponding to "git stash" and
|
||||
/// pos 27 would return the command corresponding to "git checkout main"
|
||||
/// Returns None if the pos is not in the range of the command.
|
||||
fn command_at_cursor(commands: Vec<Spanned<Command>>, pos: ByteOffset) -> Option<Spanned<Command>> {
|
||||
// First search for the in range top level command
|
||||
if let Some(in_range_command) = commands.into_iter().find(|command| {
|
||||
command.span.start() <= pos.as_usize() && command.span.end() >= pos.as_usize()
|
||||
}) {
|
||||
if let Some(in_range_part) = in_range_command
|
||||
.item
|
||||
.parts
|
||||
.iter()
|
||||
.find(|part| part.span.start() <= pos.as_usize() && part.span.end() >= pos.as_usize())
|
||||
.cloned()
|
||||
{
|
||||
match in_range_part.item {
|
||||
Part::ClosedSubshell(inner) | Part::OpenSubshell(inner) if !inner.is_empty() => {
|
||||
// If there's a subshell, recurse to find that in range command
|
||||
if let Some(sub_command) = command_at_cursor(inner, pos) {
|
||||
return Some(sub_command);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
return Some(in_range_command);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Retrieve the last unclosed command contained within a given command
|
||||
///
|
||||
/// Note: This may be the command itself, if it doesn't contain an open subshell
|
||||
fn last_unclosed_command(mut command: Spanned<Command>) -> Spanned<Command> {
|
||||
// Check if the last part of the command is an OpenSubshell with at least one subcommand
|
||||
// If it is, we repeat the process with the final command of the subshell
|
||||
if let Some(final_part) = command.item.parts.pop() {
|
||||
match final_part.item {
|
||||
Part::OpenSubshell(mut inner) => {
|
||||
if !inner.is_empty() {
|
||||
return last_unclosed_command(inner.pop().unwrap());
|
||||
} else {
|
||||
command
|
||||
.item
|
||||
.parts
|
||||
.push(Part::OpenSubshell(inner).spanned(final_part.span));
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
command.item.parts.push(final_part);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
command
|
||||
}
|
||||
|
||||
/// A parsed command, made up of a number of parts.
|
||||
///
|
||||
/// Each part represents an argument, with the first part being the command itself
|
||||
#[derive(Debug, PartialEq, Eq, Clone)]
|
||||
struct Command {
|
||||
parts: Vec<Spanned<Part>>,
|
||||
}
|
||||
|
||||
impl Command {
|
||||
pub fn new(parts: Vec<Spanned<Part>>) -> Self {
|
||||
Command { parts }
|
||||
}
|
||||
|
||||
pub fn decompose(self, src: &str) -> Vec<String> {
|
||||
let this_command = self
|
||||
.parts
|
||||
.first()
|
||||
.zip(self.parts.last())
|
||||
.map(|(first, last)| src[first.span.start()..last.span.end()].trim().to_string());
|
||||
|
||||
let mut all_commands = vec![];
|
||||
let mut this_command_has_literal = false;
|
||||
|
||||
for part in self.parts {
|
||||
match part.item {
|
||||
Part::Literal(_) => {
|
||||
this_command_has_literal = true;
|
||||
}
|
||||
Part::ClosedSubshell(s) | Part::OpenSubshell(s) => {
|
||||
// Add the total subcommand as a command.
|
||||
if let Some((first, last)) = s.first().zip(s.last()) {
|
||||
all_commands
|
||||
.push(src[first.span.start()..last.span.end()].trim().to_string());
|
||||
}
|
||||
|
||||
// Recursively decompose each command in the subcommand.
|
||||
all_commands.extend(s.into_iter().flat_map(|c| c.item.decompose(src)));
|
||||
}
|
||||
Part::Concatenated(s) => {
|
||||
// Add the total concatenation as a command.
|
||||
if let Some((first, last)) = s.first().zip(s.last()) {
|
||||
all_commands
|
||||
.push(src[first.span.start()..last.span.end()].trim().to_string());
|
||||
}
|
||||
|
||||
// Recursively decompose each part in the concatenation.
|
||||
let new_command = Command::new(s);
|
||||
all_commands.extend(new_command.decompose(src));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If this command has a literal, add it to the list.
|
||||
match this_command {
|
||||
Some(c) if this_command_has_literal => all_commands.push(c.to_string()),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
all_commands
|
||||
}
|
||||
|
||||
/// Removes the leading env-var assignments (i.e. 'KEY=VALUE' literals) from the command.
|
||||
pub fn remove_leading_env_vars(&mut self) {
|
||||
while !self.parts.is_empty() {
|
||||
let Some(first_command_part) = self.parts.first() else {
|
||||
break;
|
||||
};
|
||||
if first_command_part.to_string().split('=').count() != 2 {
|
||||
break;
|
||||
}
|
||||
self.parts.remove(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An individual part of a command (argument or command name)
|
||||
#[derive(Debug, PartialEq, Eq, Clone)]
|
||||
enum Part {
|
||||
/// Raw string value
|
||||
Literal(String),
|
||||
|
||||
/// Subshell call containing one or more commands that was properly closed
|
||||
///
|
||||
/// For example, $(cat file.txt) or `ls -la`
|
||||
ClosedSubshell(Vec<Spanned<Command>>),
|
||||
|
||||
/// Subshell call that was _not_ properly closed in the input
|
||||
///
|
||||
/// For example, $(cat file.txt or `ls -la
|
||||
OpenSubshell(Vec<Spanned<Command>>),
|
||||
|
||||
/// Concatenation of several literal and/or subshell parts
|
||||
///
|
||||
/// For example, Hello"World" would be a concatenation of /Hello/ and /"World"/ as individual
|
||||
/// parts
|
||||
Concatenated(Vec<Spanned<Part>>),
|
||||
}
|
||||
@@ -0,0 +1,460 @@
|
||||
use super::iter::ParserInput;
|
||||
use super::token::Token;
|
||||
use super::{Command, Part};
|
||||
use crate::meta::{Span, Spanned, SpannedItem};
|
||||
|
||||
/// Parser that converts a command string into a list of commands, with arguments separated
|
||||
///
|
||||
/// Handles subshells, quotes, double quotes and escapes (e.g. `\` for paths with spaces)
|
||||
pub(super) struct Parser<I>
|
||||
where
|
||||
I: IntoIterator,
|
||||
{
|
||||
tokens: ParserInput<I>,
|
||||
contains_redirection: bool,
|
||||
}
|
||||
|
||||
pub struct ParsedResult {
|
||||
pub commands: Vec<Spanned<Command>>,
|
||||
|
||||
/// Whether or not redirection operators (i.e. '>', '<') were
|
||||
/// found when parsing.
|
||||
pub contains_redirection: bool,
|
||||
}
|
||||
|
||||
impl<'a, I> Parser<I>
|
||||
where
|
||||
I: IntoIterator<Item = Spanned<Token<'a>>>,
|
||||
{
|
||||
pub fn new(input: I) -> Self {
|
||||
Self::with_tokens(ParserInput::new(input))
|
||||
}
|
||||
|
||||
fn with_tokens(tokens: ParserInput<I>) -> Self {
|
||||
Parser {
|
||||
tokens,
|
||||
contains_redirection: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(mut self) -> ParsedResult {
|
||||
let commands = self.parse_command_list(None);
|
||||
ParsedResult {
|
||||
commands,
|
||||
contains_redirection: self.contains_redirection,
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate a span from the given start to the current token position
|
||||
fn span(&self, start: usize) -> Span {
|
||||
Span::new(start, self.tokens.pos())
|
||||
}
|
||||
|
||||
/// Skip over any whitespace tokens
|
||||
fn skip_whitespace(&mut self) {
|
||||
while let Some(Token::Whitespace(_)) = self.tokens.peek() {
|
||||
self.tokens.next();
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a list of commands, separated by logical operators, pipes, semicolons, etc.
|
||||
///
|
||||
/// Can optionally include a delimiting token that will mark the end of the command list
|
||||
fn parse_command_list(&mut self, delimiter: Option<Token>) -> Vec<Spanned<Command>> {
|
||||
let mut commands = Vec::new();
|
||||
loop {
|
||||
self.skip_whitespace();
|
||||
if delimiter.is_some() && delimiter.as_ref() == self.tokens.peek() {
|
||||
break;
|
||||
}
|
||||
|
||||
// TODO: Add errors if there is a command in the list and the token isn't a logical
|
||||
// separator, as it's invalid to connect commands with a grouping operator
|
||||
match self.tokens.peek() {
|
||||
Some(Token::OpenParen) => {
|
||||
self.tokens.next();
|
||||
let nested = self.parse_command_list(Some(Token::CloseParen));
|
||||
commands.extend(nested);
|
||||
}
|
||||
Some(Token::OpenCurly) => {
|
||||
self.tokens.next();
|
||||
let nested = self.parse_command_list(Some(Token::CloseCurly));
|
||||
commands.extend(nested);
|
||||
}
|
||||
Some(Token::CloseParen | Token::CloseCurly) => {
|
||||
// If we got here, that means we are hitting a grouping close when we aren't
|
||||
// expecting it. This is generally a parse error, however for now we will treat
|
||||
// it as a separator between commands.
|
||||
// TODO: Add an error for an unexpected character
|
||||
self.tokens.next();
|
||||
}
|
||||
Some(Token::RedirectInput | Token::RedirectOutput) => {
|
||||
self.contains_redirection = true;
|
||||
self.tokens.next();
|
||||
}
|
||||
Some(t) if is_valid_command_separator(t) => {
|
||||
// Valid separator between commands, for now we naively consume it and don't
|
||||
// store semantic information about the kind of separation.
|
||||
self.tokens.next();
|
||||
}
|
||||
Some(_) => {
|
||||
// All other tokens are part of commands
|
||||
commands.push(self.parse_command());
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Add an error if the delimiter wasn't found
|
||||
|
||||
commands
|
||||
}
|
||||
|
||||
/// Parse an individual command from the input
|
||||
fn parse_command(&mut self) -> Spanned<Command> {
|
||||
let start = self.tokens.pos();
|
||||
let mut parts = Vec::new();
|
||||
loop {
|
||||
self.skip_whitespace();
|
||||
|
||||
match self.tokens.peek() {
|
||||
None => break,
|
||||
Some(Token::RedirectInput | Token::RedirectOutput) => {
|
||||
self.contains_redirection = true;
|
||||
self.tokens.next();
|
||||
}
|
||||
Some(t) if is_command_terminator(t) => {
|
||||
break;
|
||||
}
|
||||
_ => {
|
||||
parts.push(self.parse_part(parts.is_empty()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Command::new(parts).spanned(self.span(start))
|
||||
}
|
||||
|
||||
/// Parse a single part of a command (e.g. argument) from the input
|
||||
fn parse_part(&mut self, first_part: bool) -> Spanned<Part> {
|
||||
let mut builder = PartBuilder::new(self.tokens.pos());
|
||||
|
||||
loop {
|
||||
match self.tokens.peek() {
|
||||
None => break,
|
||||
Some(Token::Whitespace(_)) => break,
|
||||
Some(Token::RedirectInput | Token::RedirectOutput) => {
|
||||
self.contains_redirection = true;
|
||||
self.tokens.next();
|
||||
}
|
||||
Some(t) if is_command_terminator(t) => {
|
||||
break;
|
||||
}
|
||||
Some(Token::DoubleQuote) => {
|
||||
builder.add_part(self.parse_double_quoted_part());
|
||||
}
|
||||
Some(Token::SingleQuote) => {
|
||||
builder.add_part(self.parse_single_quoted_part());
|
||||
}
|
||||
Some(Token::Backtick) => {
|
||||
builder.add_part(self.parse_backticked_subshell());
|
||||
}
|
||||
Some(Token::Dollar) => {
|
||||
if let Some(Token::OpenParen) = self.tokens.peekpeek() {
|
||||
builder.add_part(self.parse_dollar_subshell());
|
||||
} else {
|
||||
// Consume the dollar token
|
||||
self.tokens.next();
|
||||
builder.add_raw(Token::Dollar.as_str());
|
||||
}
|
||||
}
|
||||
Some(Token::EscapeChar(c)) => {
|
||||
let c = c.to_owned();
|
||||
// Consume the backslash
|
||||
self.tokens.next();
|
||||
// In non-quoted contexts, backslash escapes all characters except newlines
|
||||
// Newline immediately following a backslash is treated as line continuation
|
||||
// Regardless, the backslash is not included in the raw value (besides some
|
||||
// special cases)
|
||||
match self.tokens.next() {
|
||||
None => {
|
||||
// Include the backslash if it is the last character in the output, as
|
||||
// there is nothing for it to escape
|
||||
builder.add_raw(c);
|
||||
}
|
||||
Some(Token::Newline) => {}
|
||||
Some(Token::Literal("~")) => {
|
||||
// Include the backslash if the input is `\~` to enable us to
|
||||
// distinguish tildes that need to be expanded into the home directory
|
||||
// and the raw string.
|
||||
builder.add_raw(c);
|
||||
builder.add_raw("~");
|
||||
}
|
||||
Some(token) => {
|
||||
// Include the backslash if this is the first part of the command, so that we
|
||||
// can support escaping aliases. eg: with alias ls=exa, the command \ls
|
||||
// should be treated as the command, and we should not remove the backslash.
|
||||
if first_part && builder.is_empty() {
|
||||
builder.add_raw(c);
|
||||
}
|
||||
builder.add_raw(token.as_str());
|
||||
}
|
||||
}
|
||||
}
|
||||
// All tokens not handled specially are treated as literals
|
||||
Some(literal) => {
|
||||
builder.add_raw(literal.as_str());
|
||||
// Consume the token
|
||||
self.tokens.next();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
builder.complete(self.tokens.pos())
|
||||
}
|
||||
|
||||
/// Parse a backticked subshell section
|
||||
///
|
||||
/// Note: This must only be called when the next character is a backtick
|
||||
fn parse_backticked_subshell(&mut self) -> Spanned<Part> {
|
||||
let start = self.tokens.pos();
|
||||
|
||||
// Consume the backtick token
|
||||
let check = self.tokens.next();
|
||||
debug_assert!(matches!(check, Some(Token::Backtick)));
|
||||
|
||||
// Create a parser using the buffered tokens between here and the next backtick
|
||||
// We can't use parse_command_list directly because Backtick isn't a command terminator,
|
||||
// and we can't make it a command terminator because it's symmetric: It also represents the
|
||||
// _start_ of a subshell.
|
||||
let mut sub_parser = Parser::with_tokens(self.tokens.until_backtick());
|
||||
let command_list = sub_parser.parse_command_list(None);
|
||||
|
||||
// Consume the closing backtick if available and classify if the subshell was closed
|
||||
match self.tokens.next() {
|
||||
None => Part::OpenSubshell(command_list).spanned(self.span(start)),
|
||||
Some(t) => {
|
||||
debug_assert!(t == Token::Backtick);
|
||||
Part::ClosedSubshell(command_list).spanned(self.span(start))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a dollar subshell section, i.e. one surrounded by $()
|
||||
///
|
||||
/// Note: This must only be called when the next two characters are $(
|
||||
fn parse_dollar_subshell(&mut self) -> Spanned<Part> {
|
||||
let start = self.tokens.pos();
|
||||
|
||||
// Consume the dollar and open paren tokens
|
||||
let check = self.tokens.next();
|
||||
debug_assert!(matches!(check, Some(Token::Dollar)));
|
||||
let check = self.tokens.next();
|
||||
debug_assert!(matches!(check, Some(Token::OpenParen)));
|
||||
|
||||
let command_list = self.parse_command_list(Some(Token::CloseParen));
|
||||
|
||||
// Consume the close paren if available and classify the subshell was closed
|
||||
match self.tokens.next() {
|
||||
None => Part::OpenSubshell(command_list).spanned(self.span(start)),
|
||||
Some(t) => {
|
||||
debug_assert!(t == Token::CloseParen);
|
||||
Part::ClosedSubshell(command_list).spanned(self.span(start))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a double-quoted section
|
||||
///
|
||||
/// Within double quotes, most tokens are treated as literal. The exceptions are:
|
||||
///
|
||||
/// - Backticks (`) still start subshell sections
|
||||
/// - $() can still be used for subshell sections
|
||||
/// - Backslash (\) escapes only some characters: $ ` " \ \n
|
||||
///
|
||||
/// Note: This must only be called when the next token is a double quote
|
||||
fn parse_double_quoted_part(&mut self) -> Spanned<Part> {
|
||||
let mut builder = PartBuilder::new(self.tokens.pos());
|
||||
|
||||
// Consume the opening double quote
|
||||
let check = self.tokens.next();
|
||||
debug_assert!(matches!(check, Some(Token::DoubleQuote)));
|
||||
|
||||
loop {
|
||||
match self.tokens.peek() {
|
||||
None => break,
|
||||
Some(Token::DoubleQuote) => {
|
||||
// Consume the closing double quote
|
||||
self.tokens.next();
|
||||
break;
|
||||
}
|
||||
Some(Token::EscapeChar(c)) => {
|
||||
let c = c.to_owned();
|
||||
// Consume the backslash
|
||||
self.tokens.next();
|
||||
// Check the following character for escape behavior
|
||||
match self.tokens.next() {
|
||||
Some(Token::Newline) => {
|
||||
// Within double quotes, newline following backslash is still treated
|
||||
// as line continuation, so neither is included in the raw output
|
||||
}
|
||||
Some(
|
||||
token @ Token::Dollar
|
||||
| token @ Token::Backtick
|
||||
| token @ Token::DoubleQuote
|
||||
| token @ Token::EscapeChar(_),
|
||||
) => {
|
||||
builder.add_raw(token.as_str());
|
||||
}
|
||||
Some(token) => {
|
||||
// For all other characters, we include the backslash as well
|
||||
builder.add_raw(c);
|
||||
builder.add_raw(token.as_str());
|
||||
}
|
||||
None => {
|
||||
builder.add_raw(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Token::Backtick) => {
|
||||
builder.add_part(self.parse_backticked_subshell());
|
||||
}
|
||||
Some(Token::Dollar) => {
|
||||
if let Some(Token::OpenParen) = self.tokens.peekpeek() {
|
||||
builder.add_part(self.parse_dollar_subshell());
|
||||
} else {
|
||||
// Consume the dollar token
|
||||
self.tokens.next();
|
||||
builder.add_raw(Token::Dollar.as_str());
|
||||
}
|
||||
}
|
||||
Some(token) => {
|
||||
builder.add_raw(token.as_str());
|
||||
// Consume the token
|
||||
self.tokens.next();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
builder.complete(self.tokens.pos())
|
||||
}
|
||||
|
||||
/// Parse a single-quoted section
|
||||
///
|
||||
/// Within single quotes, all tokens are literal until the closing single quote
|
||||
/// Note: This must only be called when the next character is a single quote
|
||||
fn parse_single_quoted_part(&mut self) -> Spanned<Part> {
|
||||
let start = self.tokens.pos();
|
||||
let mut buffer = String::new();
|
||||
|
||||
// Consume the starting single quote
|
||||
let check = self.tokens.next();
|
||||
debug_assert!(matches!(check, Some(Token::SingleQuote)));
|
||||
|
||||
while let Some(token) = self.tokens.next() {
|
||||
match token {
|
||||
Token::SingleQuote => break,
|
||||
t => buffer.push_str(t.as_str()),
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Add an error if we hit the end of the output without finding a closing quote
|
||||
|
||||
Part::Literal(buffer).spanned(self.span(start))
|
||||
}
|
||||
}
|
||||
|
||||
/// Determine if a token is a valid separator between commands
|
||||
fn is_valid_command_separator(token: &Token) -> bool {
|
||||
use Token::*;
|
||||
|
||||
matches!(
|
||||
token,
|
||||
Pipe | LogicalOr | Ampersand | LogicalAnd | Semicolon | Newline
|
||||
)
|
||||
}
|
||||
|
||||
/// Determine if a token is a terminator marking the end of a command
|
||||
///
|
||||
/// This includes all of the separator tokens as well as the grouping tokens
|
||||
fn is_command_terminator(token: &Token) -> bool {
|
||||
use Token::*;
|
||||
|
||||
is_valid_command_separator(token)
|
||||
|| matches!(token, OpenParen | CloseParen | OpenCurly | CloseCurly)
|
||||
}
|
||||
|
||||
/// Builder for handling combined command parts.
|
||||
///
|
||||
/// Tracks literal values and any nested parts, flattening `Part::Concatenated` into the current
|
||||
/// list as necessary.
|
||||
struct PartBuilder {
|
||||
start: usize,
|
||||
buffer: String,
|
||||
buffer_start: usize,
|
||||
sub_parts: Vec<Spanned<Part>>,
|
||||
}
|
||||
|
||||
impl PartBuilder {
|
||||
fn new(start: usize) -> Self {
|
||||
Self {
|
||||
start,
|
||||
buffer: String::new(),
|
||||
buffer_start: start,
|
||||
sub_parts: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a whole known sub-Part to the current Part.
|
||||
///
|
||||
/// Will complete any current Literal values into a `Part::Literal` entry.
|
||||
fn add_part(&mut self, part: Spanned<Part>) {
|
||||
if !self.buffer.is_empty() {
|
||||
let span = Span::new(self.buffer_start, part.span.start());
|
||||
let literal = Part::Literal(std::mem::take(&mut self.buffer)).spanned(span);
|
||||
self.sub_parts.push(literal);
|
||||
}
|
||||
|
||||
self.buffer_start = part.span.end();
|
||||
|
||||
match part.item {
|
||||
// Flatten any concatenated inner parts to create a single list
|
||||
Part::Concatenated(inner) => {
|
||||
self.sub_parts.extend(inner);
|
||||
}
|
||||
_ => self.sub_parts.push(part),
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a raw string value to the current part
|
||||
fn add_raw(&mut self, value: &str) {
|
||||
self.buffer.push_str(value);
|
||||
}
|
||||
|
||||
/// Complete the Part, finishing any Literal entries and determining the appropriate `Part`
|
||||
/// variant to return based on the number of entries.
|
||||
fn complete(mut self, end: usize) -> Spanned<Part> {
|
||||
if !self.buffer.is_empty() {
|
||||
let span = Span::new(self.buffer_start, end);
|
||||
let literal = Part::Literal(self.buffer).spanned(span);
|
||||
self.sub_parts.push(literal);
|
||||
}
|
||||
|
||||
let span = Span::new(self.start, end);
|
||||
match self.sub_parts.len() {
|
||||
0 => Part::Literal(String::new()).spanned(span),
|
||||
// Safety: We are checking the length, so if there is one element then pop will exist
|
||||
1 => self.sub_parts.pop().unwrap(),
|
||||
_ => Part::Concatenated(self.sub_parts).spanned(span),
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if the builder is empty.
|
||||
fn is_empty(&self) -> bool {
|
||||
self.buffer.is_empty() && self.sub_parts.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "parser_test.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,202 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use galaxy_util::path::EscapeChar;
|
||||
|
||||
use crate::parsers::simple::{decompose_command, top_level_command};
|
||||
|
||||
use super::super::lexer::Lexer;
|
||||
use super::super::{Command, Part};
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_open_subshell() {
|
||||
let source = r#"cat "Hello $(ls -la"#;
|
||||
let command = Parser::new(Lexer::new(source, EscapeChar::Backslash, false)).parse_command();
|
||||
|
||||
assert_eq!(
|
||||
command,
|
||||
Command::new(vec![
|
||||
Part::Literal("cat".into()).spanned((0, 3)),
|
||||
Part::Concatenated(vec![
|
||||
Part::Literal("Hello ".into()).spanned((4, 11)),
|
||||
Part::OpenSubshell(vec![Command::new(vec![
|
||||
Part::Literal("ls".into()).spanned((13, 15)),
|
||||
Part::Literal("-la".into()).spanned((16, 19)),
|
||||
])
|
||||
.spanned((13, 19)),])
|
||||
.spanned((11, 19)),
|
||||
])
|
||||
.spanned((4, 19)),
|
||||
])
|
||||
.spanned((0, 19)),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_nested_command() {
|
||||
let source = r#"cat "Hello $(ls -la)""#;
|
||||
let command = Parser::new(Lexer::new(source, EscapeChar::Backslash, false)).parse_command();
|
||||
|
||||
assert_eq!(
|
||||
command,
|
||||
Command::new(vec![
|
||||
Part::Literal("cat".into()).spanned((0, 3)),
|
||||
Part::Concatenated(vec![
|
||||
Part::Literal("Hello ".into()).spanned((4, 11)),
|
||||
Part::ClosedSubshell(vec![Command::new(vec![
|
||||
Part::Literal("ls".into()).spanned((13, 15)),
|
||||
Part::Literal("-la".into()).spanned((16, 19)),
|
||||
])
|
||||
.spanned((13, 19)),])
|
||||
.spanned((11, 20)),
|
||||
])
|
||||
.spanned((4, 21)),
|
||||
])
|
||||
.spanned((0, 21))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse() {
|
||||
let source = r#"ls | rm -rf || touch 'hello.txt\' &
|
||||
cat "Hello $(ls -la)" && echo `ps \`; {echo Goodbye😀}"#;
|
||||
|
||||
let commands = Parser::new(Lexer::new(source, EscapeChar::Backslash, false))
|
||||
.parse()
|
||||
.commands;
|
||||
|
||||
assert_eq!(
|
||||
commands,
|
||||
[
|
||||
Command::new(vec![Part::Literal("ls".into()).spanned((0, 2))]).spanned((0, 3)),
|
||||
Command::new(vec![
|
||||
Part::Literal("rm".into()).spanned((5, 7)),
|
||||
Part::Literal("-rf".into()).spanned((8, 11))
|
||||
])
|
||||
.spanned((5, 12)),
|
||||
Command::new(vec![
|
||||
Part::Literal("touch".into()).spanned((15, 20)),
|
||||
Part::Literal("hello.txt\\".into()).spanned((21, 33))
|
||||
])
|
||||
.spanned((15, 34)),
|
||||
Command::new(vec![
|
||||
Part::Literal("cat".into()).spanned((36, 39)),
|
||||
Part::Concatenated(vec![
|
||||
Part::Literal("Hello ".into()).spanned((40, 47)),
|
||||
Part::ClosedSubshell(vec![Command::new(vec![
|
||||
Part::Literal("ls".into()).spanned((49, 51)),
|
||||
Part::Literal("-la".into()).spanned((52, 55)),
|
||||
])
|
||||
.spanned((49, 55))])
|
||||
.spanned((47, 56)),
|
||||
])
|
||||
.spanned((40, 57))
|
||||
])
|
||||
.spanned((36, 58)),
|
||||
Command::new(vec![
|
||||
Part::Literal("echo".into()).spanned((61, 65)),
|
||||
Part::ClosedSubshell(vec![Command::new(vec![
|
||||
Part::Literal("ps".into()).spanned((67, 69)),
|
||||
Part::Literal("\\".into()).spanned((70, 71)),
|
||||
])
|
||||
.spanned((67, 71))])
|
||||
.spanned((66, 72)),
|
||||
])
|
||||
.spanned((61, 72)),
|
||||
Command::new(vec![
|
||||
Part::Literal("echo".into()).spanned((75, 79)),
|
||||
Part::Literal("Goodbye😀".into()).spanned((80, 91))
|
||||
])
|
||||
.spanned((75, 91)),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
// Test that a backslash is retained when preceding a command.
|
||||
#[test]
|
||||
fn test_backslash_before_command() {
|
||||
let source = r#"\ls"#;
|
||||
let command = Parser::new(Lexer::new(source, EscapeChar::Backslash, false)).parse_command();
|
||||
|
||||
assert_eq!(
|
||||
command,
|
||||
Command::new(vec![Part::Literal(r"\ls".into()).spanned((0, 3))]).spanned((0, 3))
|
||||
);
|
||||
}
|
||||
|
||||
// Test that a backslash is not retained in the middle of a command.
|
||||
#[test]
|
||||
fn test_backslash_in_command() {
|
||||
let source = r#"ls \-la"#;
|
||||
let command = Parser::new(Lexer::new(source, EscapeChar::Backslash, false)).parse_command();
|
||||
|
||||
assert_eq!(
|
||||
command,
|
||||
Command::new(vec![
|
||||
Part::Literal("ls".into()).spanned((0, 2)),
|
||||
Part::Literal("-la".into()).spanned((3, 7))
|
||||
])
|
||||
.spanned((0, 7))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decompose_command() {
|
||||
let test_data = vec![
|
||||
("ls", vec!["ls"]),
|
||||
("$(ls)", vec!["ls"]),
|
||||
("ls -la", vec!["ls -la"]),
|
||||
("ls && cat", vec!["ls", "cat"]),
|
||||
(
|
||||
"ls $(foo | echo)",
|
||||
vec!["foo", "echo", "foo | echo", "ls $(foo | echo)"],
|
||||
),
|
||||
];
|
||||
|
||||
for (input, expected_output) in test_data {
|
||||
// Compare with hashsets bc we don't care about ordering.
|
||||
assert_eq!(
|
||||
HashSet::<String>::from_iter(decompose_command(input, EscapeChar::Backslash).0),
|
||||
HashSet::from_iter(expected_output.into_iter().map(ToString::to_string)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_contains_redirection() {
|
||||
let test_data = vec![
|
||||
("ls < \"file.txt < tmp\"", true),
|
||||
("echo $(ls > file.txt)", true),
|
||||
("ls >> file.txt", true),
|
||||
("ls < file.txt", true),
|
||||
("foo arg1 arg2 > file.txt", true),
|
||||
("foo && ls > file.txt", true),
|
||||
("echo \"hello world\" > output.txt", true),
|
||||
("echo \"5>4\"", false),
|
||||
("echo \"This message -> shows direction\"", false),
|
||||
("print(\"Value must be > 0 and < 100\")", false),
|
||||
];
|
||||
|
||||
for (cmd, should_contain_redirection) in test_data {
|
||||
let parser = Parser::new(Lexer::new(cmd, EscapeChar::Backslash, false));
|
||||
let contains_redirection = parser.parse().contains_redirection;
|
||||
assert_eq!(contains_redirection, should_contain_redirection);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_top_level_command() {
|
||||
let test_data = vec![
|
||||
("PAGER=0 git log", Some("git")),
|
||||
("PAGER= git log", Some("git")),
|
||||
("ls && git status", Some("ls")),
|
||||
("$(git status)", None),
|
||||
];
|
||||
|
||||
for (input, expected_output) in test_data {
|
||||
assert_eq!(
|
||||
top_level_command(input, EscapeChar::Backslash),
|
||||
expected_output.map(ToString::to_string)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum Token<'a> {
|
||||
Literal(&'a str),
|
||||
/// Whitespace excluding newline
|
||||
Whitespace(&'a str),
|
||||
/// | Operator
|
||||
Pipe,
|
||||
/// || operator
|
||||
LogicalOr,
|
||||
/// & operator
|
||||
Ampersand,
|
||||
/// && operator
|
||||
LogicalAnd,
|
||||
Semicolon,
|
||||
Newline,
|
||||
Backtick,
|
||||
OpenParen,
|
||||
CloseParen,
|
||||
OpenCurly,
|
||||
CloseCurly,
|
||||
Dollar,
|
||||
SingleQuote,
|
||||
DoubleQuote,
|
||||
/// \ or `
|
||||
EscapeChar(&'a str),
|
||||
RedirectInput,
|
||||
RedirectOutput,
|
||||
}
|
||||
|
||||
impl Token<'_> {
|
||||
pub fn as_str(&self) -> &str {
|
||||
use Token::*;
|
||||
match self {
|
||||
Literal(value) | Whitespace(value) | EscapeChar(value) => value,
|
||||
Pipe => "|",
|
||||
LogicalOr => "||",
|
||||
Ampersand => "&",
|
||||
LogicalAnd => "&&",
|
||||
Semicolon => ";",
|
||||
Newline => "\n",
|
||||
Backtick => "`",
|
||||
OpenParen => "(",
|
||||
CloseParen => ")",
|
||||
OpenCurly => "{",
|
||||
CloseCurly => "}",
|
||||
Dollar => "$",
|
||||
SingleQuote => "'",
|
||||
DoubleQuote => "\"",
|
||||
RedirectInput => "<",
|
||||
RedirectOutput => ">",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
use itertools::Itertools;
|
||||
use galaxy_util::path::EscapeChar;
|
||||
|
||||
use crate::{
|
||||
parsers::{
|
||||
classify_command,
|
||||
hir::{CommandCallInfo, Flags, ShellCommand},
|
||||
simple::parse_for_completions,
|
||||
ClassifiedCommand,
|
||||
},
|
||||
signatures::testing::{create_test_command_registry, test_signature},
|
||||
};
|
||||
|
||||
#[cfg(not(feature = "v2"))]
|
||||
use crate::parsers::hir::{Flag, FlagType};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
pub fn test_classify_command_classifies_known_command() {
|
||||
let registry = create_test_command_registry([test_signature()]);
|
||||
|
||||
let lite_command = parse_for_completions("test ", EscapeChar::Backslash, false)
|
||||
.expect("Should be able to parse input into LiteCommand");
|
||||
let mut tokens = lite_command.parts.iter().map(|s| s.as_str()).collect_vec();
|
||||
|
||||
let classified_command = classify_command(
|
||||
lite_command.clone(),
|
||||
&mut tokens,
|
||||
®istry,
|
||||
TopLevelCommandCaseSensitivity::CaseSensitive,
|
||||
);
|
||||
assert_eq!(
|
||||
classified_command,
|
||||
Some(ClassifiedCommand {
|
||||
env_vars: vec![],
|
||||
command: Command::Classified(ShellCommand {
|
||||
name: "test".to_owned(),
|
||||
name_span: Span::from((0, 4)),
|
||||
args: CommandCallInfo {
|
||||
command_name: Spanned {
|
||||
span: Span::from((0, 4)),
|
||||
item: ParsedExpression::new(
|
||||
Expression::Command,
|
||||
ParsedToken("test".to_owned())
|
||||
),
|
||||
},
|
||||
positionals: None,
|
||||
flags: Some(Flags::new()),
|
||||
ending_whitespace: Some(Span::from((4, 5))),
|
||||
span: Span::from((0, 5))
|
||||
}
|
||||
}),
|
||||
error: None,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/// TODO(CORE-2797)
|
||||
#[cfg(not(feature = "v2"))]
|
||||
#[test]
|
||||
pub fn test_classify_command_classifies_known_command_with_flags() {
|
||||
let registry = create_test_command_registry([test_signature()]);
|
||||
|
||||
let lite_command = parse_for_completions("test -r --long foo", EscapeChar::Backslash, false)
|
||||
.expect("Should be able to parse input into LiteCommand");
|
||||
let mut tokens = lite_command.parts.iter().map(|s| s.as_str()).collect_vec();
|
||||
|
||||
let classified_command = classify_command(
|
||||
lite_command.clone(),
|
||||
&mut tokens,
|
||||
®istry,
|
||||
TopLevelCommandCaseSensitivity::CaseSensitive,
|
||||
);
|
||||
assert_eq!(
|
||||
classified_command,
|
||||
Some(ClassifiedCommand {
|
||||
env_vars: vec![],
|
||||
command: Command::Classified(ShellCommand {
|
||||
name: "test".to_owned(),
|
||||
name_span: Span::from((0, 4)),
|
||||
args: CommandCallInfo {
|
||||
command_name: Spanned {
|
||||
span: Span::from((0, 4)),
|
||||
item: ParsedExpression::new(
|
||||
Expression::Command,
|
||||
ParsedToken("test".to_owned())
|
||||
),
|
||||
},
|
||||
positionals: None,
|
||||
flags: Some(Flags {
|
||||
flags: vec![
|
||||
Flag {
|
||||
name: "-r".to_owned(),
|
||||
name_span: Span::from((5, 7)),
|
||||
flag_type: FlagType::NoArgument
|
||||
},
|
||||
Flag {
|
||||
name: "--long".to_owned(),
|
||||
name_span: Span::from((8, 14)),
|
||||
flag_type: FlagType::Argument {
|
||||
value: Spanned {
|
||||
span: Span::from((15, 18)),
|
||||
item: ParsedExpression::new(
|
||||
Expression::Literal,
|
||||
ParsedToken("foo".to_owned())
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
]
|
||||
}),
|
||||
ending_whitespace: None,
|
||||
span: Span::from((0, 18))
|
||||
}
|
||||
}),
|
||||
error: None
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/// TODO(CORE-2797)
|
||||
///
|
||||
/// With exact option matching, `-r` correctly matches the `-r` switch (no arguments),
|
||||
/// so the parser advances past it and discovers the `one` subcommand. The command path
|
||||
/// becomes `"test -r one"` (the legacy parser's convention for subcommand paths).
|
||||
#[cfg(not(feature = "v2"))]
|
||||
#[test]
|
||||
pub fn test_classify_command_classifies_known_command_with_subcommand() {
|
||||
let registry = create_test_command_registry([test_signature()]);
|
||||
|
||||
let lite_command = parse_for_completions("test -r one foo bar", EscapeChar::Backslash, false)
|
||||
.expect("Should be able to parse input into LiteCommand");
|
||||
let mut tokens = lite_command.parts.iter().map(|s| s.as_str()).collect_vec();
|
||||
|
||||
let classified_command = classify_command(
|
||||
lite_command.clone(),
|
||||
&mut tokens,
|
||||
®istry,
|
||||
TopLevelCommandCaseSensitivity::CaseSensitive,
|
||||
);
|
||||
assert_eq!(
|
||||
classified_command,
|
||||
Some(ClassifiedCommand {
|
||||
env_vars: vec![],
|
||||
command: Command::Classified(ShellCommand {
|
||||
name: "test -r one".to_owned(),
|
||||
name_span: Span::from((0, 11)),
|
||||
args: CommandCallInfo {
|
||||
command_name: Spanned {
|
||||
span: Span::from((0, 11)),
|
||||
item: ParsedExpression::new(
|
||||
Expression::Command,
|
||||
ParsedToken("test -r one".to_owned())
|
||||
),
|
||||
},
|
||||
positionals: Some(vec![
|
||||
Spanned {
|
||||
span: Span::from((12, 15)),
|
||||
item: ParsedExpression::new(
|
||||
Expression::Literal,
|
||||
ParsedToken("foo".to_owned())
|
||||
),
|
||||
},
|
||||
Spanned {
|
||||
span: Span::from((16, 19)),
|
||||
item: ParsedExpression::new(
|
||||
Expression::Literal,
|
||||
ParsedToken("bar".to_owned())
|
||||
),
|
||||
},
|
||||
]),
|
||||
flags: Some(Flags::new()),
|
||||
ending_whitespace: None,
|
||||
span: Span::from((0, 19))
|
||||
}
|
||||
}),
|
||||
error: None
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
pub fn test_classify_command_classifies_unknown_command() {
|
||||
let registry = create_test_command_registry([]);
|
||||
|
||||
let lite_command = parse_for_completions("test ", EscapeChar::Backslash, false)
|
||||
.expect("Should be able to parse input into LiteCommand");
|
||||
let mut tokens = lite_command.parts.iter().map(|s| s.as_str()).collect_vec();
|
||||
|
||||
let classified_command = classify_command(
|
||||
lite_command.clone(),
|
||||
&mut tokens,
|
||||
®istry,
|
||||
TopLevelCommandCaseSensitivity::CaseSensitive,
|
||||
);
|
||||
assert_eq!(
|
||||
classified_command,
|
||||
Some(ClassifiedCommand {
|
||||
env_vars: vec![],
|
||||
command: Command::Unclassified(ExternalCommand {
|
||||
name: ParsedToken("test".to_owned()),
|
||||
name_span: Span::from((0, 4)),
|
||||
args: CommandCallInfo {
|
||||
command_name: Spanned {
|
||||
span: Span::from((0, 5)),
|
||||
item: ParsedExpression::new(
|
||||
Expression::Literal,
|
||||
ParsedToken("test".to_owned())
|
||||
),
|
||||
},
|
||||
positionals: None,
|
||||
flags: None,
|
||||
ending_whitespace: Some(Span::from((4, 5))),
|
||||
span: Span::from((0, 5))
|
||||
}
|
||||
}),
|
||||
error: None,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
pub fn test_classify_command_classifies_unknown_command_with_flags() {
|
||||
let registry = create_test_command_registry([]);
|
||||
|
||||
let lite_command = parse_for_completions("test -r --long foo", EscapeChar::Backslash, false)
|
||||
.expect("Should be able to parse input into LiteCommand");
|
||||
let mut tokens = lite_command.parts.iter().map(|s| s.as_str()).collect_vec();
|
||||
|
||||
let classified_command = classify_command(
|
||||
lite_command.clone(),
|
||||
&mut tokens,
|
||||
®istry,
|
||||
TopLevelCommandCaseSensitivity::CaseSensitive,
|
||||
);
|
||||
assert_eq!(
|
||||
classified_command,
|
||||
Some(ClassifiedCommand {
|
||||
env_vars: vec![],
|
||||
command: Command::Unclassified(ExternalCommand {
|
||||
name: ParsedToken("test".to_owned()),
|
||||
name_span: Span::from((0, 4)),
|
||||
args: CommandCallInfo {
|
||||
command_name: Spanned {
|
||||
span: Span::from((0, 18)),
|
||||
item: ParsedExpression::new(
|
||||
Expression::Literal,
|
||||
ParsedToken("test".to_owned()),
|
||||
),
|
||||
},
|
||||
positionals: Some(vec![
|
||||
Spanned {
|
||||
span: Span::from((5, 7)),
|
||||
item: ParsedExpression::new(
|
||||
Expression::Literal,
|
||||
ParsedToken("-r".to_owned()),
|
||||
),
|
||||
},
|
||||
Spanned {
|
||||
span: Span::from((8, 14)),
|
||||
item: ParsedExpression::new(
|
||||
Expression::Literal,
|
||||
ParsedToken("--long".to_owned()),
|
||||
),
|
||||
},
|
||||
Spanned {
|
||||
span: Span::from((15, 18)),
|
||||
item: ParsedExpression::new(
|
||||
Expression::Literal,
|
||||
ParsedToken("foo".to_owned()),
|
||||
),
|
||||
},
|
||||
]),
|
||||
flags: None,
|
||||
ending_whitespace: None,
|
||||
span: Span::from((0, 18)),
|
||||
},
|
||||
}),
|
||||
error: None,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
pub fn test_classify_command_classifies_unknown_command_with_subcommand() {
|
||||
let registry = create_test_command_registry([]);
|
||||
|
||||
let lite_command = parse_for_completions("test -r one foo bar", EscapeChar::Backslash, false)
|
||||
.expect("Should be able to parse input into LiteCommand");
|
||||
let mut tokens = lite_command.parts.iter().map(|s| s.as_str()).collect_vec();
|
||||
|
||||
let classified_command = classify_command(
|
||||
lite_command.clone(),
|
||||
&mut tokens,
|
||||
®istry,
|
||||
TopLevelCommandCaseSensitivity::CaseSensitive,
|
||||
);
|
||||
assert_eq!(
|
||||
classified_command,
|
||||
Some(ClassifiedCommand {
|
||||
env_vars: vec![],
|
||||
command: Command::Unclassified(ExternalCommand {
|
||||
name: ParsedToken("test".to_owned()),
|
||||
name_span: Span::from((0, 4)),
|
||||
args: CommandCallInfo {
|
||||
command_name: Spanned {
|
||||
span: Span::from((0, 19)),
|
||||
item: ParsedExpression::new(
|
||||
Expression::Literal,
|
||||
ParsedToken("test".to_owned()),
|
||||
),
|
||||
},
|
||||
positionals: Some(vec![
|
||||
Spanned {
|
||||
span: Span::from((5, 7)),
|
||||
item: ParsedExpression::new(
|
||||
Expression::Literal,
|
||||
ParsedToken("-r".to_owned()),
|
||||
),
|
||||
},
|
||||
Spanned {
|
||||
span: Span::from((8, 11)),
|
||||
item: ParsedExpression::new(
|
||||
Expression::Literal,
|
||||
ParsedToken("one".to_owned()),
|
||||
),
|
||||
},
|
||||
Spanned {
|
||||
span: Span::from((12, 15)),
|
||||
item: ParsedExpression::new(
|
||||
Expression::Literal,
|
||||
ParsedToken("foo".to_owned()),
|
||||
),
|
||||
},
|
||||
Spanned {
|
||||
span: Span::from((16, 19)),
|
||||
item: ParsedExpression::new(
|
||||
Expression::Literal,
|
||||
ParsedToken("bar".to_owned())
|
||||
),
|
||||
},
|
||||
]),
|
||||
flags: None,
|
||||
ending_whitespace: None,
|
||||
span: Span::from((0, 19)),
|
||||
},
|
||||
}),
|
||||
error: None,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_classify_command_case_sensitive() {
|
||||
let registry = create_test_command_registry([test_signature()]);
|
||||
|
||||
let lite_command = parse_for_completions("TEST ", EscapeChar::Backslash, false)
|
||||
.expect("Should be able to parse input into LiteCommand");
|
||||
let mut tokens = lite_command.parts.iter().map(|s| s.as_str()).collect_vec();
|
||||
|
||||
let classified_command = classify_command(
|
||||
lite_command.clone(),
|
||||
&mut tokens,
|
||||
®istry,
|
||||
TopLevelCommandCaseSensitivity::CaseSensitive,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
classified_command,
|
||||
Some(ClassifiedCommand {
|
||||
env_vars: vec![],
|
||||
command: Command::Unclassified(ExternalCommand {
|
||||
name: ParsedToken("TEST".to_owned()),
|
||||
name_span: Span::from((0, 4)),
|
||||
args: CommandCallInfo {
|
||||
command_name: Spanned {
|
||||
span: Span::from((0, 5)),
|
||||
item: ParsedExpression::new(
|
||||
Expression::Literal,
|
||||
ParsedToken("TEST".to_owned())
|
||||
),
|
||||
},
|
||||
positionals: None,
|
||||
flags: None,
|
||||
ending_whitespace: Some(Span::from((4, 5))),
|
||||
span: Span::from((0, 5))
|
||||
}
|
||||
}),
|
||||
error: None,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/// TODO(CORE-2810)
|
||||
#[cfg(not(feature = "v2"))]
|
||||
#[test]
|
||||
fn test_classify_command_case_insensitive() {
|
||||
let registry = create_test_command_registry([test_signature()]);
|
||||
|
||||
let lite_command = parse_for_completions("TEST ", EscapeChar::Backslash, false)
|
||||
.expect("Should be able to parse input into LiteCommand");
|
||||
let mut tokens = lite_command.parts.iter().map(|s| s.as_str()).collect_vec();
|
||||
|
||||
let classified_command = classify_command(
|
||||
lite_command.clone(),
|
||||
&mut tokens,
|
||||
®istry,
|
||||
TopLevelCommandCaseSensitivity::CaseInsensitive,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
classified_command,
|
||||
Some(ClassifiedCommand {
|
||||
env_vars: vec![],
|
||||
command: Command::Classified(ShellCommand {
|
||||
name: "TEST".to_owned(),
|
||||
name_span: Span::from((0, 4)),
|
||||
args: CommandCallInfo {
|
||||
command_name: Spanned {
|
||||
span: Span::from((0, 4)),
|
||||
item: ParsedExpression::new(
|
||||
Expression::Command,
|
||||
ParsedToken("TEST".to_owned())
|
||||
),
|
||||
},
|
||||
positionals: None,
|
||||
flags: Some(Flags::new()),
|
||||
ending_whitespace: Some(Span::from((4, 5))),
|
||||
span: Span::from((0, 5))
|
||||
}
|
||||
}),
|
||||
error: None,
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
//! Contains the V2 implementation of internal command parsing logic that depends on the new,
|
||||
//! JS-compatible command signature struct (`crate::signatures::CommandSignature`).
|
||||
use crate::signatures::{
|
||||
get_matching_signature_for_tokenized_input, Command, CommandRegistry, Opt,
|
||||
};
|
||||
use crate::{
|
||||
completer::TopLevelCommandCaseSensitivity,
|
||||
meta::{HasSpan, Span, Spanned, SpannedItem},
|
||||
};
|
||||
|
||||
use super::parse_unclassified_command;
|
||||
use super::{
|
||||
hir::{self, Expression, Flags, ShellCommand},
|
||||
parse_arg, parse_dollar_expr, ArgumentError, FlagArgumentsCardinality, FlagSignature,
|
||||
LiteCommand, ParseError, ParsedExpression, ParsedToken,
|
||||
};
|
||||
|
||||
pub(super) fn parse_command(
|
||||
lite_cmd: &LiteCommand,
|
||||
tokens: &[&str],
|
||||
command_registry: &CommandRegistry,
|
||||
// TODO(CORE-2810)
|
||||
_command_case_sensitivity: TopLevelCommandCaseSensitivity,
|
||||
) -> (Option<hir::Command>, Option<ParseError>) {
|
||||
let mut error: Option<ParseError> = None;
|
||||
|
||||
if lite_cmd.parts.is_empty() {
|
||||
return (None, None);
|
||||
}
|
||||
|
||||
if let Some((found_signature, token_index)) = get_matching_signature_for_tokenized_input(
|
||||
tokens,
|
||||
lite_cmd.post_whitespace.is_some(),
|
||||
command_registry,
|
||||
) {
|
||||
let (internal_command, err) =
|
||||
parse_internal_command(lite_cmd, found_signature, token_index);
|
||||
|
||||
error = error.or(err);
|
||||
return (Some(hir::Command::Classified(internal_command)), error);
|
||||
}
|
||||
|
||||
let (command, error) = parse_unclassified_command(lite_cmd);
|
||||
(Some(command), error)
|
||||
}
|
||||
|
||||
// This is a forked version of `super::parse_internal_command` that works with the V2 `CommandSignature` struct.
|
||||
fn parse_internal_command(
|
||||
lite_command: &LiteCommand,
|
||||
command_signature: &Command,
|
||||
mut command_token_index: usize,
|
||||
) -> (ShellCommand, Option<ParseError>) {
|
||||
log::debug!("parsing internal command {lite_command:?}");
|
||||
|
||||
// This is a known internal command, so we need to work with the arguments and parse them according to the expected types
|
||||
let (name, name_span) = (
|
||||
lite_command.parts[0..(command_token_index + 1)]
|
||||
.iter()
|
||||
.map(|x| x.item.clone())
|
||||
.collect::<Vec<String>>()
|
||||
.join(" "),
|
||||
Span::new(
|
||||
lite_command.parts[0].span.start(),
|
||||
lite_command.parts[command_token_index].span.end(),
|
||||
),
|
||||
);
|
||||
|
||||
let mut internal_command = ShellCommand::new(ParsedToken(name), name_span, lite_command.span());
|
||||
internal_command.args.flags = Some(Flags::new());
|
||||
internal_command.args.ending_whitespace = lite_command.post_whitespace;
|
||||
|
||||
let mut current_positional = 0;
|
||||
let mut named = Flags::new();
|
||||
let mut positional = vec![];
|
||||
let mut error = None;
|
||||
command_token_index += 1; // Start where the arguments begin
|
||||
|
||||
while command_token_index < lite_command.parts.len() {
|
||||
if lite_command.parts[command_token_index]
|
||||
.item
|
||||
.starts_with('-')
|
||||
&& lite_command.parts[command_token_index].item.len() > 1
|
||||
{
|
||||
let (named_types, err) = get_flag_signature_spec(
|
||||
command_signature,
|
||||
&internal_command,
|
||||
&lite_command.parts[command_token_index],
|
||||
);
|
||||
|
||||
if err.is_none() {
|
||||
for FlagSignature {
|
||||
name: full_name,
|
||||
is_switch,
|
||||
arguments_cardinality,
|
||||
arguments,
|
||||
} in named_types
|
||||
{
|
||||
if is_switch {
|
||||
// Switch flag (without arguments)
|
||||
named.insert_flag_with_no_argument(
|
||||
full_name,
|
||||
lite_command.parts[command_token_index].span,
|
||||
);
|
||||
} else if lite_command.parts[command_token_index].item.contains('=') {
|
||||
// Self-contained option (--key=value)
|
||||
let mut offset = 0;
|
||||
|
||||
let value = lite_command.parts[command_token_index]
|
||||
.item
|
||||
.chars()
|
||||
.skip_while(|prop| {
|
||||
offset += 1;
|
||||
*prop != '='
|
||||
})
|
||||
.nth(1);
|
||||
|
||||
offset = if value.is_none() { offset - 1 } else { offset };
|
||||
|
||||
let flag_value = Span::new(
|
||||
lite_command.parts[command_token_index].span.start() + offset,
|
||||
lite_command.parts[command_token_index].span.end(),
|
||||
);
|
||||
let value = lite_command.parts[command_token_index].item[offset..]
|
||||
.to_string()
|
||||
.spanned(flag_value);
|
||||
// We expect there to be exactly one arg in the case of a --key=value flag.
|
||||
let arg_signature = arguments.first();
|
||||
let (arg, err) = parse_arg(&value, arg_signature);
|
||||
named.insert_flag_with_argument(
|
||||
full_name,
|
||||
lite_command.parts[command_token_index].span,
|
||||
arg,
|
||||
);
|
||||
|
||||
error = error.or(err);
|
||||
} else if command_token_index == lite_command.parts.len() - 1 {
|
||||
// Named argument with missing value
|
||||
error = error.or_else(|| {
|
||||
Some(ParseError::argument_error(
|
||||
lite_command.parts[0].clone(),
|
||||
ArgumentError::MissingValueForName {
|
||||
name: full_name
|
||||
.spanned(lite_command.parts[command_token_index].span),
|
||||
missing_arg_index: 0,
|
||||
},
|
||||
))
|
||||
});
|
||||
} else {
|
||||
// Named argument with following value(s).
|
||||
// Since an option can have multiple arguments (any of which can be variadic),
|
||||
// we should exhaust as many following args as possible.
|
||||
let flag_idx = command_token_index;
|
||||
|
||||
let end = match arguments_cardinality {
|
||||
FlagArgumentsCardinality::Variadic => lite_command.parts.len(),
|
||||
FlagArgumentsCardinality::Fixed(num_args) => flag_idx + num_args + 1,
|
||||
};
|
||||
let mut argument_idx = command_token_index + 1;
|
||||
|
||||
// Exhaust as many args as we expect but stop early if we see another option.
|
||||
// Note that since we are incrementing index again in the outer loop. Let's check
|
||||
// boundary on the NEXT token rather than the current token.
|
||||
while argument_idx < end.min(lite_command.parts.len())
|
||||
&& !lite_command
|
||||
.parts
|
||||
.get(argument_idx)
|
||||
.is_some_and(|part| part.item.starts_with('-'))
|
||||
{
|
||||
let arg_signature_idx = argument_idx - flag_idx - 1;
|
||||
// Even though the completion spec technically allows for a variadic arg to not be the last arg,
|
||||
// it does not make sense and doesn't happen in practice, so we assume it's the last.
|
||||
let arg_signature = if arg_signature_idx >= arguments.len()
|
||||
&& matches!(
|
||||
arguments_cardinality,
|
||||
FlagArgumentsCardinality::Variadic
|
||||
) {
|
||||
arguments.last()
|
||||
} else {
|
||||
arguments.get(arg_signature_idx)
|
||||
};
|
||||
let (arg, err) =
|
||||
parse_arg(&lite_command.parts[argument_idx], arg_signature);
|
||||
named.insert_flag_with_argument(
|
||||
full_name.clone(),
|
||||
lite_command.parts[flag_idx].span,
|
||||
arg,
|
||||
);
|
||||
error = error.or(err);
|
||||
argument_idx += 1;
|
||||
}
|
||||
|
||||
// If there were a fixed number of arguments for the option, make sure
|
||||
// they were all exhausted. Otherwise, we're missing an arg.
|
||||
if matches!(arguments_cardinality, FlagArgumentsCardinality::Fixed(_))
|
||||
&& argument_idx != end
|
||||
{
|
||||
error = error.or_else(|| {
|
||||
Some(ParseError::argument_error(
|
||||
lite_command.parts[0].clone(),
|
||||
ArgumentError::MissingValueForName {
|
||||
name: full_name
|
||||
.spanned(lite_command.parts[command_token_index].span),
|
||||
missing_arg_index: argument_idx - flag_idx - 1,
|
||||
},
|
||||
))
|
||||
});
|
||||
}
|
||||
|
||||
// argument_idx here is one index overshoot of the last argument
|
||||
// token. Set the current index to be the index of last argument.
|
||||
command_token_index = argument_idx - 1;
|
||||
|
||||
// We consumed the argument(s) for the option, so we should stop iterating the
|
||||
// possible matching flags. This case shouldn't happen as CLIs don't
|
||||
// generally support adding multiple flags with values in a single position
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
positional.push(
|
||||
ParsedExpression::new(
|
||||
Expression::Unknown,
|
||||
ParsedToken(lite_command.parts[command_token_index].item.clone()),
|
||||
)
|
||||
.spanned(lite_command.parts[command_token_index].span),
|
||||
);
|
||||
|
||||
error = error.or(err);
|
||||
}
|
||||
} else if !command_signature.arguments.is_empty()
|
||||
&& command_signature.arguments.len() > current_positional
|
||||
{
|
||||
let arg = {
|
||||
// TODO: pass v2 Argument signature to parse_arg which only accepts v1 Argument
|
||||
// let arg_signature = &command_signature.arguments[current_positional];
|
||||
let (expr, err) = parse_arg(&lite_command.parts[command_token_index], None);
|
||||
|
||||
error = error.or(err);
|
||||
expr
|
||||
};
|
||||
|
||||
positional.push(arg);
|
||||
current_positional += 1;
|
||||
} else if let Some(_arg_signature) = command_signature
|
||||
.arguments
|
||||
.iter()
|
||||
.rfind(|a| a.is_variadic())
|
||||
{
|
||||
// TODO: pass v2 Argument signature to parse_arg which only accepts v1 Argument
|
||||
let (arg, err) = parse_arg(&lite_command.parts[command_token_index], None);
|
||||
error = error.or(err);
|
||||
|
||||
positional.push(arg);
|
||||
current_positional += 1;
|
||||
} else {
|
||||
let expression = if lite_command.parts[command_token_index]
|
||||
.item
|
||||
.starts_with('$')
|
||||
{
|
||||
parse_dollar_expr(&lite_command.parts[command_token_index])
|
||||
} else {
|
||||
ParsedExpression::new(
|
||||
Expression::Unknown,
|
||||
ParsedToken(lite_command.parts[command_token_index].item.clone()),
|
||||
)
|
||||
.spanned(lite_command.parts[command_token_index].span)
|
||||
};
|
||||
|
||||
positional.push(expression);
|
||||
|
||||
error = error.or_else(|| {
|
||||
Some(ParseError::argument_error(
|
||||
lite_command.parts[0].clone(),
|
||||
ArgumentError::UnexpectedArgument(
|
||||
lite_command.parts[command_token_index].clone(),
|
||||
),
|
||||
))
|
||||
});
|
||||
}
|
||||
|
||||
command_token_index += 1;
|
||||
}
|
||||
|
||||
let command_arguments = &command_signature.arguments;
|
||||
|
||||
// Count the required positional arguments and ensure these have been met
|
||||
let mut required_arg_count = 0;
|
||||
for positional_arg in command_arguments.iter() {
|
||||
if !positional_arg.optional {
|
||||
required_arg_count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if positional.len() < required_arg_count {
|
||||
let arg = &command_arguments[positional.len()];
|
||||
error = error.or_else(|| {
|
||||
Some(ParseError::argument_error(
|
||||
lite_command.parts[0].clone(),
|
||||
ArgumentError::MissingMandatoryPositional {
|
||||
name: Some(arg.name.clone()),
|
||||
positional_index: positional.len(),
|
||||
},
|
||||
))
|
||||
});
|
||||
}
|
||||
|
||||
if !named.is_empty() {
|
||||
internal_command.args.flags = Some(named);
|
||||
}
|
||||
|
||||
if !positional.is_empty() {
|
||||
internal_command.args.positionals = Some(positional);
|
||||
}
|
||||
|
||||
(internal_command, error)
|
||||
}
|
||||
|
||||
fn get_flag_signature_spec<'a>(
|
||||
command_signature: &'a Command,
|
||||
cmd: &'a ShellCommand,
|
||||
arg: &'a Spanned<String>,
|
||||
) -> (Vec<FlagSignature<'a>>, Option<ParseError>) {
|
||||
if arg.item.starts_with('-') {
|
||||
// It's a flag (or set of flags)
|
||||
let mut output = vec![];
|
||||
let mut error = None;
|
||||
|
||||
let remainder: String = arg.item.chars().skip(1).collect();
|
||||
|
||||
if remainder.starts_with('-') {
|
||||
// Long flag expected
|
||||
let mut remainder: String = remainder.chars().skip(1).collect();
|
||||
|
||||
if remainder.contains('=') {
|
||||
let assignment: Vec<_> = remainder.split('=').collect();
|
||||
|
||||
if assignment.len() != 2 {
|
||||
error = Some(ParseError::argument_error(
|
||||
cmd.name.to_string().spanned(cmd.name_span),
|
||||
ArgumentError::InvalidValueForFlag(arg.clone()),
|
||||
));
|
||||
} else {
|
||||
remainder = assignment[0].to_string();
|
||||
}
|
||||
}
|
||||
|
||||
let mut found_remainder = false;
|
||||
for option in command_signature.options.iter() {
|
||||
if longhand_representations_for_opt(option).contains(&remainder) {
|
||||
output.push(FlagSignature {
|
||||
name: remainder.clone(),
|
||||
is_switch: option.arguments.is_empty(),
|
||||
// TODO: pass v2 Argument signature to parse_arg which only accepts v1 Argument
|
||||
arguments: &[],
|
||||
arguments_cardinality: FlagArgumentsCardinality::from(option),
|
||||
});
|
||||
found_remainder = true;
|
||||
}
|
||||
}
|
||||
|
||||
if !found_remainder {
|
||||
error = Some(ParseError::argument_error(
|
||||
cmd.name.to_string().spanned(cmd.name_span),
|
||||
ArgumentError::UnexpectedFlag(arg.clone()),
|
||||
));
|
||||
}
|
||||
} else {
|
||||
// Short flag(s) expected
|
||||
let mut starting_pos = arg.span.start() + 1;
|
||||
for c in remainder.chars() {
|
||||
let mut found = false;
|
||||
|
||||
for option in command_signature.options.iter() {
|
||||
if shorthand_representations_for_opt(option).contains(&c.to_string()) {
|
||||
// TODO(alokedesai): Check if we should be using short or long here
|
||||
output.push(FlagSignature {
|
||||
name: c.to_string(),
|
||||
is_switch: option.arguments.is_empty(),
|
||||
// TODO: pass v2 Argument signature to parse_arg which only accepts v1 Argument
|
||||
arguments: &[],
|
||||
arguments_cardinality: FlagArgumentsCardinality::from(option),
|
||||
});
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
error = Some(ParseError::argument_error(
|
||||
cmd.name.to_string().spanned(cmd.name_span),
|
||||
ArgumentError::UnexpectedFlag(
|
||||
arg.item
|
||||
.clone()
|
||||
.spanned(Span::new(starting_pos, starting_pos + c.len_utf8())),
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
starting_pos += c.len_utf8();
|
||||
}
|
||||
}
|
||||
|
||||
(output, error)
|
||||
} else {
|
||||
// It's not a flag, so don't bother with it
|
||||
(vec![], None)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&Opt> for FlagArgumentsCardinality {
|
||||
fn from(option: &Opt) -> Self {
|
||||
if option.arguments.iter().any(|arg| arg.is_variadic()) {
|
||||
FlagArgumentsCardinality::Variadic
|
||||
} else {
|
||||
FlagArgumentsCardinality::Fixed(
|
||||
option.arguments.iter().filter(|arg| !arg.optional).count(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn shorthand_representations_for_opt(opt: &Opt) -> Vec<String> {
|
||||
opt.name
|
||||
.iter()
|
||||
.filter(|s| s.starts_with('-') && !s.starts_with("--"))
|
||||
.map(|s| s[1..].to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn longhand_representations_for_opt(opt: &Opt) -> Vec<String> {
|
||||
opt.name
|
||||
.iter()
|
||||
.filter(|s| s.starts_with("--"))
|
||||
.map(|s| s[2..].to_string())
|
||||
.collect()
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
use clap::{Arg, Command as ClapCommand};
|
||||
use warp_command_signatures::{
|
||||
Argument, IsArgumentOptional, Opt, ParserDirectives, Priority, Signature,
|
||||
};
|
||||
|
||||
/// Convert a [`clap::Command`] into a [`Signature`]. All subcommands, options, and arguments are
|
||||
/// preserved on a best-effort basis.
|
||||
pub fn signature_from_clap_command(cmd: &mut ClapCommand, bin_name: &str) -> Signature {
|
||||
cmd.set_bin_name(bin_name);
|
||||
// Building the command sets all sorts of derived properties like Args::get_num_args.
|
||||
cmd.build();
|
||||
convert_command(cmd, cmd.get_bin_name().expect("Set above").to_string())
|
||||
}
|
||||
|
||||
fn convert_command(cmd: &ClapCommand, name: String) -> Signature {
|
||||
let description = cmd.get_about().map(|s| s.to_string());
|
||||
|
||||
let arguments = convert_positional_args(cmd);
|
||||
let options = convert_options(cmd);
|
||||
let subcommands = convert_subcommands(cmd);
|
||||
|
||||
Signature {
|
||||
name,
|
||||
alias_generator: None,
|
||||
description,
|
||||
arguments,
|
||||
subcommands,
|
||||
options,
|
||||
priority: Priority::default(),
|
||||
parser_directives: ParserDirectives::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert clap positional arguments to Signature arguments.
|
||||
fn convert_positional_args(cmd: &ClapCommand) -> Option<Vec<Argument>> {
|
||||
let positional_args: Vec<Argument> = cmd
|
||||
.get_positionals()
|
||||
.filter(|arg| !arg.is_hide_set())
|
||||
.map(convert_arg_to_argument)
|
||||
.collect();
|
||||
|
||||
if positional_args.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(positional_args)
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert clap options/flags to Signature options.
|
||||
fn convert_options(cmd: &ClapCommand) -> Option<Vec<Opt>> {
|
||||
let opts: Vec<Opt> = cmd
|
||||
.get_opts()
|
||||
.filter(|arg| !arg.is_positional() && !arg.is_hide_set())
|
||||
.map(convert_arg_to_opt)
|
||||
.collect();
|
||||
|
||||
if opts.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(opts)
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert clap subcommands to Signature subcommands by recursively converting each subcommand.
|
||||
fn convert_subcommands(cmd: &ClapCommand) -> Option<Vec<Signature>> {
|
||||
let subcommands: Vec<Signature> = cmd
|
||||
.get_subcommands()
|
||||
.filter(|subcmd| !subcmd.is_hide_set())
|
||||
.flat_map(|cmd| {
|
||||
std::iter::once(cmd.get_name())
|
||||
.chain(cmd.get_visible_aliases())
|
||||
.map(|cmd_or_alias| convert_command(cmd, cmd_or_alias.to_string()))
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.collect();
|
||||
|
||||
if subcommands.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(subcommands)
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a [`clap::Arg`] to a positional [`Argument`].
|
||||
fn convert_arg_to_argument(arg: &Arg) -> Argument {
|
||||
let display_name = Some(arg.get_id().to_string());
|
||||
let description = arg.get_help().map(|s| s.to_string());
|
||||
let optional = if arg.is_required_set() {
|
||||
IsArgumentOptional::Required
|
||||
} else {
|
||||
IsArgumentOptional::Optional(
|
||||
arg.get_default_values()
|
||||
.first()
|
||||
.map(|s| s.to_string_lossy().to_string()),
|
||||
)
|
||||
};
|
||||
|
||||
Argument {
|
||||
display_name,
|
||||
description,
|
||||
is_variadic: arg.get_num_args().is_some_and(|num_args| {
|
||||
num_args.takes_values() && num_args.min_values() != num_args.max_values()
|
||||
}),
|
||||
// TODO: Extract argument types from clap value hints.
|
||||
argument_types: vec![],
|
||||
optional,
|
||||
is_command: false,
|
||||
skip_generator_validation: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a [`clap::Arg]` to an [`Opt`] representing a flag/option.
|
||||
fn convert_arg_to_opt(arg: &Arg) -> Opt {
|
||||
let mut exact_string = Vec::new();
|
||||
|
||||
// Add short flags (e.g., "-h")
|
||||
for short in arg.get_short_and_visible_aliases().into_iter().flatten() {
|
||||
exact_string.push(format!("-{short}"));
|
||||
}
|
||||
|
||||
// Add long flags (e.g., "--help")
|
||||
for long in arg.get_long_and_visible_aliases().into_iter().flatten() {
|
||||
exact_string.push(format!("--{long}"));
|
||||
}
|
||||
|
||||
let description = arg.get_help().map(|s| s.to_string());
|
||||
let required = arg.is_required_set();
|
||||
|
||||
let arguments = arg.get_num_args().and_then(|num_args| {
|
||||
if num_args.takes_values() {
|
||||
// TODO: Handle multi-valued flags. The Clap documentation is fairly unclear on how
|
||||
// it models this (e.g. can we assume that get_default_values and get_value names are
|
||||
// paired? Why is there only one ValueHint?). We currently don't need support for this.
|
||||
|
||||
Some(vec![Argument {
|
||||
display_name: arg
|
||||
.get_value_names()
|
||||
.and_then(|names| names.first())
|
||||
.map(|s| s.to_string()),
|
||||
description: None,
|
||||
is_variadic: false,
|
||||
// TODO: Infer from ValuesHint.
|
||||
argument_types: vec![],
|
||||
optional: arg
|
||||
.get_default_values()
|
||||
.first()
|
||||
.map(|s| IsArgumentOptional::Optional(Some(s.to_string_lossy().to_string())))
|
||||
.unwrap_or(IsArgumentOptional::Required),
|
||||
is_command: false,
|
||||
skip_generator_validation: true,
|
||||
}])
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
Opt {
|
||||
exact_string,
|
||||
description,
|
||||
arguments,
|
||||
required,
|
||||
priority: Priority::default(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
use galaxy_core::channel::Channel;
|
||||
|
||||
pub mod registry;
|
||||
|
||||
pub use registry::CommandRegistry;
|
||||
#[cfg(feature = "test-util")]
|
||||
use warp_command_signatures::Signature;
|
||||
|
||||
static GLOBAL_REGISTRY: OnceLock<Arc<CommandRegistry>> = OnceLock::new();
|
||||
|
||||
impl CommandRegistry {
|
||||
/// Returns a reference to a single global instance of the command registry.
|
||||
///
|
||||
/// The registry is a read-only store of information used to provide smart
|
||||
/// suggestions and completions, and as such, only one instance is required
|
||||
/// across the application. The registry itself can be quite large, so use
|
||||
/// of a single global instance avoids unnecessary memory allocations and
|
||||
/// usage.
|
||||
pub fn global_instance() -> Arc<Self> {
|
||||
GLOBAL_REGISTRY
|
||||
.get_or_init(|| {
|
||||
// TODO(wasm): Determine how to asynchronously load command signatures on wasm.
|
||||
Arc::new(CommandRegistry::new_with_embedded_signatures())
|
||||
})
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// Returns a new [`CommandRegistry`] that looks up commands in the embedded
|
||||
/// set of command signatures.
|
||||
fn new_with_embedded_signatures() -> Self {
|
||||
let registry = CommandRegistry::new(
|
||||
|command| {
|
||||
let start = instant::Instant::now();
|
||||
let signature = warp_command_signatures::signature_by_name(command);
|
||||
log::debug!(
|
||||
"Lazily loaded command signature for {command} in {}s",
|
||||
start.elapsed().as_secs_f32()
|
||||
);
|
||||
signature
|
||||
},
|
||||
warp_command_signatures::dynamic_command_signature_data(),
|
||||
);
|
||||
|
||||
Self::register_warp_signatures(®istry);
|
||||
|
||||
registry
|
||||
}
|
||||
|
||||
/// Register signatures for Warp CLI commands.
|
||||
///
|
||||
/// Ideally this would be done outside of the `galaxy_completer` crate, but it's not currently
|
||||
/// possible to configure the shared [`Self::global_instance`].
|
||||
fn register_warp_signatures(registry: &Self) {
|
||||
// We use the current instance's signature for each channel. This is not entirely accurate - for example:
|
||||
// * The user might be SSHed into a host with a different version of the CLI
|
||||
// * The user might be using Preview, which will have different features than Stable.
|
||||
// However, it'll be close enough, and this approach ensures that we keep the CLI completions up to date.
|
||||
let channels = [Channel::Stable, Channel::Preview, Channel::Dev];
|
||||
|
||||
for channel in channels {
|
||||
let bin_name = channel.cli_command_name();
|
||||
let mut clap_cmd = galaxy_cli::Args::clap_command();
|
||||
let signature =
|
||||
crate::signatures::clap::signature_from_clap_command(&mut clap_cmd, bin_name);
|
||||
registry.register_signature(signature);
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns an empty [`CommandRegistry`] that contains no signatures nor
|
||||
/// generators.
|
||||
pub fn empty() -> Self {
|
||||
CommandRegistry::new(|_| None, std::collections::HashMap::new())
|
||||
}
|
||||
|
||||
/// Returns a [`CommandRegistry`] that uses the provided set of signatures
|
||||
/// and generators. This does not utilize any data from the
|
||||
/// warp-command-signatures crate.
|
||||
#[cfg(feature = "test-util")]
|
||||
pub fn new_for_test(
|
||||
signatures: impl IntoIterator<Item = Signature>,
|
||||
generators: std::collections::HashMap<
|
||||
String,
|
||||
warp_command_signatures::DynamicCompletionData,
|
||||
>,
|
||||
) -> Self {
|
||||
let registry = CommandRegistry::new(|_| None, generators);
|
||||
signatures
|
||||
.into_iter()
|
||||
.for_each(|signature| registry.register_signature(signature));
|
||||
registry
|
||||
}
|
||||
}
|
||||
|
||||
// We only implement Default for this in tests, as in production, we should
|
||||
// always use the shared instance, but in tests, we might want to configure
|
||||
// instances differently.
|
||||
#[cfg(feature = "test-util")]
|
||||
impl Default for CommandRegistry {
|
||||
fn default() -> Self {
|
||||
CommandRegistry::new_with_embedded_signatures()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,518 @@
|
||||
use crate::completer::{CommandExitStatus, CompletionContext, TopLevelCommandCaseSensitivity};
|
||||
use crate::parsers::SignatureAtTokenIndex;
|
||||
|
||||
use itertools::Itertools;
|
||||
use memo_map::MemoMap;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use warp_command_signatures::{Argument, DynamicCompletionData, IsArgumentOptional, Signature};
|
||||
|
||||
pub enum SignatureResult<'a> {
|
||||
/// Successfully parsed the signature. We are returning the signature at the token to complete on.
|
||||
Success(SignatureAtTokenIndex<'a>),
|
||||
/// The command contains an alias. We are returning the expanded command to re-run the parser.
|
||||
NeedAliasExpansion(String),
|
||||
/// Couldn't find a signature.
|
||||
None,
|
||||
}
|
||||
|
||||
type SignatureLookupFn = dyn 'static + Send + Sync + Fn(&str) -> Option<Signature>;
|
||||
|
||||
/// A simple structure to cache parsed command signatures. These are stored as
|
||||
/// JSON, so this makes it easy for us to lazily load and parse the JSON when
|
||||
/// a command signature is needed, and only need to do that parsing work once
|
||||
/// per signature per run of the program.
|
||||
struct SignatureCache {
|
||||
/// A function that, given the name of a command, returns the [`Signature`]
|
||||
/// for it. Should return None if there is no signature available for the
|
||||
/// given command.
|
||||
lookup_fn: Box<SignatureLookupFn>,
|
||||
/// A map from command name to the signature for the command, if any. The
|
||||
/// use of [`MemoMap`] here allows us to safely return references to the
|
||||
/// contained signatures (as the map internally is an append-only
|
||||
/// structure). This stores an `Option<Signature>` in order to also store
|
||||
/// our knowledge of commands for which we do _not_ have a signature.
|
||||
signatures: MemoMap<String, Option<Signature>>,
|
||||
}
|
||||
|
||||
impl SignatureCache {
|
||||
fn new(lookup_fn: Box<SignatureLookupFn>) -> Self {
|
||||
Self {
|
||||
lookup_fn,
|
||||
signatures: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn get(&self, command: &str) -> Option<&Signature> {
|
||||
let command = if cfg!(windows) {
|
||||
command.trim_end_matches(".exe")
|
||||
} else {
|
||||
command
|
||||
};
|
||||
let command = command.to_lowercase();
|
||||
self.signatures
|
||||
.get_or_insert(&command, || (self.lookup_fn)(&command))
|
||||
.as_ref()
|
||||
}
|
||||
|
||||
/// Inserts the given `Signature` into the underlying map, keyed by `Signature::name`.
|
||||
///
|
||||
/// If there is already a cached value for the given `Signature::name`, this is a no-op (even
|
||||
/// if the cached value is `None`).
|
||||
fn insert(&self, signature: Signature) {
|
||||
self.signatures
|
||||
.insert(signature.name.to_lowercase(), Some(signature));
|
||||
}
|
||||
}
|
||||
|
||||
/// This is a wrapper around a HashMap<String, T> to enforce the invariant that all keys must be
|
||||
/// all lowercase letters.
|
||||
#[derive(Clone, Debug)]
|
||||
struct CaseInsensitiveHashMap<T> {
|
||||
map: HashMap<String, T>,
|
||||
}
|
||||
|
||||
impl<T> Default for CaseInsensitiveHashMap<T> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
map: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> CaseInsensitiveHashMap<T> {
|
||||
fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
fn get(&self, key: &str) -> Option<&T> {
|
||||
self.map.get(&key.to_lowercase())
|
||||
}
|
||||
|
||||
fn insert(&mut self, key: &str, val: T) -> Option<T> {
|
||||
self.map.insert(key.to_lowercase(), val)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> FromIterator<(String, T)> for CaseInsensitiveHashMap<T> {
|
||||
fn from_iter<I: IntoIterator<Item = (String, T)>>(iter: I) -> Self {
|
||||
let mut map = Self::new();
|
||||
for (key, val) in iter.into_iter() {
|
||||
map.insert(&key, val);
|
||||
}
|
||||
map
|
||||
}
|
||||
}
|
||||
|
||||
pub struct CommandRegistry {
|
||||
signatures: SignatureCache,
|
||||
dynamic_completion_data: CaseInsensitiveHashMap<DynamicCompletionData>,
|
||||
}
|
||||
|
||||
impl CommandRegistry {
|
||||
pub(super) fn new<F>(
|
||||
signature_lookup_fn: F,
|
||||
dynamic_completion_data: HashMap<String, DynamicCompletionData>,
|
||||
) -> CommandRegistry
|
||||
where
|
||||
F: 'static + Send + Sync + Fn(&str) -> Option<Signature>,
|
||||
{
|
||||
CommandRegistry {
|
||||
signatures: SignatureCache::new(Box::new(signature_lookup_fn)),
|
||||
dynamic_completion_data: dynamic_completion_data.into_iter().collect(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn registered_commands(&self) -> impl Iterator<Item = &str> {
|
||||
// Note we need to collect the keys because MemoMap uses a mutex under the hood to control
|
||||
// access to the underlying signature data. This means the mutex is locked as long as the
|
||||
// iterator returned from `keys()` lives, which means we need to collect keys into a vec
|
||||
// and return an owned iterator.
|
||||
self.signatures
|
||||
.signatures
|
||||
.iter()
|
||||
.filter_map(|(key, signature)| signature.as_ref().map(|_| key.as_str()))
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter()
|
||||
}
|
||||
|
||||
pub fn signature_from_line(
|
||||
&self,
|
||||
line: &str,
|
||||
command_case_sensitivity: TopLevelCommandCaseSensitivity,
|
||||
) -> Option<SignatureAtTokenIndex<'_>> {
|
||||
let names = line.split_whitespace().collect_vec();
|
||||
self.signature_from_tokens(
|
||||
&names,
|
||||
line.ends_with(char::is_whitespace),
|
||||
command_case_sensitivity,
|
||||
)
|
||||
}
|
||||
|
||||
/// Returns a replacement [`Signature`] and its corresponding [`DynamicCompletionData`] iff
|
||||
/// the current signature has an argument that should be a top level command and we are in a
|
||||
/// position where we would be completing on arguments.
|
||||
/// For example: if we had a token list of `sudo git ` (note the whitespace) we should return
|
||||
/// the `Signature` for `git`.
|
||||
///
|
||||
/// NOTE this function does not handle the case where the `Signature` has multiple arguments
|
||||
/// and an argument other than the first should be a top level command. Fig also does not
|
||||
/// support this case, see CORE-2154 for more details.
|
||||
fn maybe_load_replacement_signature(
|
||||
&self,
|
||||
signature: &Signature,
|
||||
tokens: &[&str],
|
||||
current_index: usize,
|
||||
token: &str,
|
||||
has_post_whitespace: bool,
|
||||
) -> Option<(&Signature, Option<&DynamicCompletionData>)> {
|
||||
if !signature.arguments().iter().any(Argument::is_command) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let is_last_token = tokens.len() - 1 == current_index;
|
||||
|
||||
let replacement_signature = self.signatures.get(token).map(|signature| {
|
||||
(
|
||||
signature,
|
||||
self.dynamic_completion_data.get(signature.name()),
|
||||
)
|
||||
})?;
|
||||
|
||||
if is_last_token {
|
||||
has_post_whitespace.then_some(replacement_signature)
|
||||
} else {
|
||||
Some(replacement_signature)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn signature_with_alias_expansion(
|
||||
&self,
|
||||
tokens: &[&str],
|
||||
has_post_whitespace: bool,
|
||||
context: &dyn CompletionContext,
|
||||
) -> SignatureResult<'_> {
|
||||
let found_signature = tokens.first().and_then(|command| {
|
||||
let command = if cfg!(windows) {
|
||||
command.trim_end_matches(".exe")
|
||||
} else {
|
||||
command
|
||||
};
|
||||
self.signatures
|
||||
.get(command)
|
||||
.map(|signature| (signature, self.dynamic_completion_data.get(command)))
|
||||
});
|
||||
|
||||
let Some((signature, mut dynamic_completion_data)) = found_signature else {
|
||||
return SignatureResult::None;
|
||||
};
|
||||
|
||||
let mut signature_start_idx = 0;
|
||||
let mut curr_signature = signature;
|
||||
|
||||
// Iterate through tokens after the top level command
|
||||
let mut token_idx = 1;
|
||||
while token_idx < tokens.len() {
|
||||
// If at last token, and there's no post-whitespace, don't actually try to resolve
|
||||
// this token as an alias since we're actually completing on that token itself.
|
||||
if token_idx == tokens.len() - 1 && !has_post_whitespace {
|
||||
break;
|
||||
}
|
||||
|
||||
let token = tokens[token_idx];
|
||||
// Check if there is any alias at the current signature.
|
||||
if let Some(alias) =
|
||||
curr_signature.alias(dynamic_completion_data.map(DynamicCompletionData::aliases))
|
||||
{
|
||||
// Get the shell command to execute for getting the alias.
|
||||
let command_to_run = alias.command(&tokens[..token_idx + 1]);
|
||||
|
||||
if let Some(generator_context) = context.generator_context() {
|
||||
if let Ok(output) = generator_context
|
||||
.execute_command_at_pwd(&command_to_run, None)
|
||||
.await
|
||||
{
|
||||
if let Ok(output_string) = output.to_string() {
|
||||
// If the command output was successful, attempt to complete on the alias.
|
||||
match output.status {
|
||||
CommandExitStatus::Success => {
|
||||
let expanded_command =
|
||||
alias.on_complete(&output_string, tokens, token_idx);
|
||||
|
||||
if let Some(expanded_command) = expanded_command {
|
||||
return SignatureResult::NeedAliasExpansion(
|
||||
expanded_command,
|
||||
);
|
||||
}
|
||||
}
|
||||
CommandExitStatus::Failure => {
|
||||
// We purposefully do not log an error here if the command failed because
|
||||
// many commands (such as `git`) will fail if there isn't a valid alias for
|
||||
// the token.
|
||||
log::debug!(
|
||||
"Execution of `{}` failed with output: {}",
|
||||
command_to_run,
|
||||
&output_string
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log::debug!(
|
||||
"Execution of `{command_to_run}` returned an unparseable output",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some((replacement_signature, replacement_completion_data)) = self
|
||||
.maybe_load_replacement_signature(
|
||||
signature,
|
||||
tokens,
|
||||
token_idx,
|
||||
token,
|
||||
has_post_whitespace,
|
||||
)
|
||||
{
|
||||
curr_signature = replacement_signature;
|
||||
dynamic_completion_data = replacement_completion_data;
|
||||
signature_start_idx = token_idx;
|
||||
} else {
|
||||
match classify_token(
|
||||
curr_signature,
|
||||
token,
|
||||
tokens.len(),
|
||||
token_idx,
|
||||
has_post_whitespace,
|
||||
) {
|
||||
TokenAction::ResolvedSubcommand { signature } => {
|
||||
curr_signature = signature;
|
||||
signature_start_idx = token_idx;
|
||||
}
|
||||
TokenAction::SkippedOption { advance_by } => {
|
||||
token_idx += advance_by;
|
||||
}
|
||||
TokenAction::SkippedUnrecognizedFlag => {}
|
||||
TokenAction::VariadicOption | TokenAction::StopAtCurrentToken => {
|
||||
return SignatureResult::Success(SignatureAtTokenIndex::new(
|
||||
curr_signature,
|
||||
dynamic_completion_data,
|
||||
signature_start_idx,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
token_idx += 1;
|
||||
}
|
||||
|
||||
SignatureResult::Success(SignatureAtTokenIndex::new(
|
||||
curr_signature,
|
||||
dynamic_completion_data,
|
||||
signature_start_idx,
|
||||
))
|
||||
}
|
||||
|
||||
/// Finds a signature from a list of tokens--returning the index of the token where the
|
||||
/// signature starts.
|
||||
pub fn signature_from_tokens(
|
||||
&self,
|
||||
tokens: &[&str],
|
||||
has_post_whitespace: bool,
|
||||
command_case_sensitivity: TopLevelCommandCaseSensitivity,
|
||||
) -> Option<SignatureAtTokenIndex<'_>> {
|
||||
let first_token = *tokens.first()?;
|
||||
|
||||
// Find the top level signature.
|
||||
let (signature, mut dynamic_completion_data) = self
|
||||
.signatures
|
||||
.get(first_token)
|
||||
.map(|signature| (signature, self.dynamic_completion_data.get(first_token)))?;
|
||||
|
||||
// Signature lookup is case-insensitive. However, sometimes we need to treat the lookup as
|
||||
// case-sensitive. There are 2 variables to check for that. The first is the
|
||||
// `command_case_sensitivity` parameter which represents the platform's filesystem
|
||||
// case-sensitivity. This, however, may be overridden by
|
||||
// `ParserDirectives::always_case_insensitive`. When that is true, we ignore the platform.
|
||||
// If we are treating this as a case-sensitive lookup, `signature.name` will contain the
|
||||
// canonical stylization of the name, and so we compare what the user typed, `first_token`,
|
||||
// to that.
|
||||
// For example, on Linux (case-sensitive by default), "GIT" should not match the spec for
|
||||
// "git". `first_token` will be "GIT" and `signature.name` will be "git". We return `None`.
|
||||
// However, if the user is running PowerShell and calls "set-location", this _should_ match
|
||||
// the spec for "Set-Location", so we skip the `signature.name != first_token` check. FYI
|
||||
// `signature.name` will be formatted as "Set-Location" as that is the preferred style.
|
||||
if command_case_sensitivity == TopLevelCommandCaseSensitivity::CaseSensitive
|
||||
&& !signature.parser_directives.always_case_insensitive
|
||||
&& signature.name != first_token
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut signature_start_idx = 0;
|
||||
let mut curr_signature = signature;
|
||||
|
||||
// Iterate through tokens after the top level command
|
||||
let mut token_idx = 1;
|
||||
while token_idx < tokens.len() {
|
||||
let token = tokens[token_idx];
|
||||
|
||||
if let Some((replacement_signature, replacement_completion_data)) = self
|
||||
.maybe_load_replacement_signature(
|
||||
signature,
|
||||
tokens,
|
||||
token_idx,
|
||||
token,
|
||||
has_post_whitespace,
|
||||
)
|
||||
{
|
||||
curr_signature = replacement_signature;
|
||||
dynamic_completion_data = replacement_completion_data;
|
||||
signature_start_idx = token_idx;
|
||||
} else {
|
||||
match classify_token(
|
||||
curr_signature,
|
||||
token,
|
||||
tokens.len(),
|
||||
token_idx,
|
||||
has_post_whitespace,
|
||||
) {
|
||||
TokenAction::ResolvedSubcommand { signature } => {
|
||||
curr_signature = signature;
|
||||
signature_start_idx = token_idx;
|
||||
}
|
||||
TokenAction::SkippedOption { advance_by } => {
|
||||
token_idx += advance_by;
|
||||
}
|
||||
TokenAction::SkippedUnrecognizedFlag => {}
|
||||
TokenAction::VariadicOption | TokenAction::StopAtCurrentToken => {
|
||||
return Some(SignatureAtTokenIndex::new(
|
||||
curr_signature,
|
||||
dynamic_completion_data,
|
||||
signature_start_idx,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
token_idx += 1;
|
||||
}
|
||||
|
||||
Some(SignatureAtTokenIndex::new(
|
||||
curr_signature,
|
||||
dynamic_completion_data,
|
||||
signature_start_idx,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn signature(&self, name: &str) -> Option<&Signature> {
|
||||
self.signatures.get(name)
|
||||
}
|
||||
|
||||
/// Registers the given `Signature`.
|
||||
///
|
||||
/// Note the underlying map caches the lookup result for a given signature (regardless of
|
||||
/// whether or not it is `Some` or `None`), which means that if there is already a cached
|
||||
/// `None` value for the command corresponding to this signature, this is a no-op.
|
||||
pub fn register_signature(&self, signature: Signature) {
|
||||
self.signatures.insert(signature);
|
||||
}
|
||||
}
|
||||
|
||||
/// The result of classifying a single token during signature resolution.
|
||||
enum TokenAction<'a> {
|
||||
/// The token matched a subcommand of the current signature.
|
||||
ResolvedSubcommand { signature: &'a Signature },
|
||||
/// The token matched a recognized option whose last argument is variadic.
|
||||
/// The caller should stop walking tokens and return the current signature.
|
||||
VariadicOption,
|
||||
/// The token matched a recognized option with a fixed number of required
|
||||
/// arguments. The caller should advance `token_idx` by `advance_by` to skip
|
||||
/// past those arguments (the flag token itself is advanced separately).
|
||||
SkippedOption { advance_by: usize },
|
||||
/// The token starts with '-' but didn't match any recognized option.
|
||||
SkippedUnrecognizedFlag,
|
||||
/// The token is not a subcommand, option, or flag-like. The caller should
|
||||
/// stop walking tokens and return the current signature.
|
||||
StopAtCurrentToken,
|
||||
}
|
||||
|
||||
/// Classifies a token against the current signature's subcommands and options.
|
||||
///
|
||||
/// This encapsulates the shared per-token decision logic. Callers
|
||||
/// handle replacement signatures (e.g. `sudo git`) separately before
|
||||
/// invoking this function.
|
||||
fn classify_token<'a>(
|
||||
curr_signature: &'a Signature,
|
||||
token: &str,
|
||||
num_tokens: usize,
|
||||
token_idx: usize,
|
||||
has_post_whitespace: bool,
|
||||
) -> TokenAction<'a> {
|
||||
if let Some(subcommand) = curr_signature.subcommands().iter().find(|s| {
|
||||
should_complete_on_subcmd(s.name(), token, num_tokens, token_idx, has_post_whitespace)
|
||||
}) {
|
||||
return TokenAction::ResolvedSubcommand {
|
||||
signature: subcommand,
|
||||
};
|
||||
}
|
||||
|
||||
if let Some(option) = find_option_by_name(curr_signature.options(), token) {
|
||||
if option.arguments().last().is_some_and(|arg| arg.is_variadic) {
|
||||
return TokenAction::VariadicOption;
|
||||
}
|
||||
let required_arg_count = option
|
||||
.arguments()
|
||||
.iter()
|
||||
.filter(|arg| arg.optional == IsArgumentOptional::Required)
|
||||
.count();
|
||||
return TokenAction::SkippedOption {
|
||||
advance_by: required_arg_count,
|
||||
};
|
||||
}
|
||||
|
||||
if token.starts_with('-') {
|
||||
return TokenAction::SkippedUnrecognizedFlag;
|
||||
}
|
||||
|
||||
TokenAction::StopAtCurrentToken
|
||||
}
|
||||
|
||||
/// Finds an option by exact name match against the token.
|
||||
fn find_option_by_name<'a>(
|
||||
options: &'a [warp_command_signatures::Opt],
|
||||
token: &str,
|
||||
) -> Option<&'a warp_command_signatures::Opt> {
|
||||
options
|
||||
.iter()
|
||||
.find(|option| option.exact_string.iter().any(|s| s == token))
|
||||
}
|
||||
|
||||
/// Returns true iff we should resolve the subcmd as a new [`Signature`].
|
||||
///
|
||||
/// If token is the last token, then as long as the subcmd matches the token name and there's whitespace at the end,
|
||||
/// then we should complete on the signature. If there wasn't whitespace at the end, then we would actually want to
|
||||
/// complete on the subcmds, and not resolve this subcmd. For example, suppoes the line is 'npm r' vs 'npm r '.
|
||||
/// In the former, we want to find `npm` subcommand completions. In the latter, we want to find `npm r` completions.
|
||||
///
|
||||
/// Otherwise, we are not at the last token so we should recursively resolve as long as the subcmd matches the token.
|
||||
fn should_complete_on_subcmd(
|
||||
subcmd_name: &str,
|
||||
token: &str,
|
||||
num_tokens: usize,
|
||||
curr_token_idx: usize,
|
||||
has_post_whitespace: bool,
|
||||
) -> bool {
|
||||
let is_last_token = num_tokens - 1 == curr_token_idx;
|
||||
let subcmd_matches_token = subcmd_name == token;
|
||||
|
||||
if is_last_token {
|
||||
has_post_whitespace && subcmd_matches_token
|
||||
} else {
|
||||
subcmd_matches_token
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "registry_test.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,234 @@
|
||||
use crate::completer::testing::FakeCompletionContext;
|
||||
use crate::completer::CompletionContext;
|
||||
use crate::completer::TopLevelCommandCaseSensitivity;
|
||||
use crate::signatures::registry::SignatureResult;
|
||||
use crate::signatures::testing::{create_test_command_registry, test_signature};
|
||||
|
||||
#[test]
|
||||
fn test_find_command_from_a_top_level_signature() {
|
||||
let bundle = warp_command_signatures::signature_by_name("bundle")
|
||||
.expect("global command signatures should include 'bundle'");
|
||||
|
||||
let registry = create_test_command_registry([bundle.clone(), test_signature()]);
|
||||
|
||||
let found_signature = registry
|
||||
.signature_from_line(
|
||||
"bundle exec ",
|
||||
TopLevelCommandCaseSensitivity::CaseSensitive,
|
||||
)
|
||||
.map(|c| c.signature);
|
||||
let signature = bundle;
|
||||
let exec_subcommand = signature
|
||||
.subcommands()
|
||||
.iter()
|
||||
.find(|sig| sig.name() == "exec");
|
||||
assert_eq!(found_signature, exec_subcommand);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_subcommand_signature_with_flags() {
|
||||
let kubectl = warp_command_signatures::signature_by_name("kubectl")
|
||||
.expect("global command signatures should include 'kubectl'");
|
||||
|
||||
let registry = create_test_command_registry([kubectl.clone(), test_signature()]);
|
||||
|
||||
let found_signature = registry
|
||||
.signature_from_line(
|
||||
"kubectl -n default get ",
|
||||
TopLevelCommandCaseSensitivity::CaseSensitive,
|
||||
)
|
||||
.expect("kubectl signature from line should exist");
|
||||
// Should parse this as entering the "get" subcommand even though there's a top level -n default flag.
|
||||
assert_eq!(found_signature.signature.name(), "get");
|
||||
assert_eq!(found_signature.token_index, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_option_by_name_exact_match_does_not_match_substring() {
|
||||
// Regression test: "-n" should match the "-n"/"--namespace" option, NOT
|
||||
// "--no-headers" (which contains the substring "-n"). The fix uses exact
|
||||
// equality instead of `contains`.
|
||||
let kubectl = warp_command_signatures::signature_by_name("kubectl")
|
||||
.expect("global command signatures should include 'kubectl'");
|
||||
|
||||
let registry = create_test_command_registry([kubectl, test_signature()]);
|
||||
|
||||
// "kubectl -n default api-resources " should resolve to "api-resources",
|
||||
// which has a "--no-headers" option. If "-n" incorrectly matched
|
||||
// "--no-headers" via substring, the parser would skip "default" as the
|
||||
// flag argument and never reach the "api-resources" subcommand.
|
||||
let found_signature = registry
|
||||
.signature_from_line(
|
||||
"kubectl -n default api-resources ",
|
||||
TopLevelCommandCaseSensitivity::CaseSensitive,
|
||||
)
|
||||
.expect("kubectl signature from line should exist");
|
||||
assert_eq!(found_signature.signature.name(), "api-resources");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_flag_arg_consumes_token_matching_subcommand_name() {
|
||||
// When a recognized flag takes a required argument, the next token should be consumed
|
||||
// as that flag's argument even if it happens to match a subcommand name.
|
||||
// Here, --not-long takes 1 argument, so "one" is consumed as that argument
|
||||
// rather than being resolved as the "one" subcommand.
|
||||
let registry = create_test_command_registry([test_signature()]);
|
||||
|
||||
let found_signature = registry
|
||||
.signature_from_line(
|
||||
"test --not-long one foo ",
|
||||
TopLevelCommandCaseSensitivity::CaseSensitive,
|
||||
)
|
||||
.expect("test signature from line should exist");
|
||||
assert_eq!(found_signature.signature.name(), "test");
|
||||
assert_eq!(found_signature.token_index, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multiple_switches_before_subcommand() {
|
||||
// Multiple switch flags (no arguments) before a subcommand should all be
|
||||
// skipped, allowing the subcommand to be discovered.
|
||||
let registry = create_test_command_registry([test_signature()]);
|
||||
|
||||
let found_signature = registry
|
||||
.signature_from_line(
|
||||
"test -r -V one foo ",
|
||||
TopLevelCommandCaseSensitivity::CaseSensitive,
|
||||
)
|
||||
.expect("test signature from line should exist");
|
||||
assert_eq!(found_signature.signature.name(), "one");
|
||||
assert_eq!(found_signature.token_index, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unrecognized_flag_skipped_before_subcommand() {
|
||||
// Unrecognized flags (tokens starting with '-' not in the spec) should be
|
||||
// skipped so the parser can still discover subcommands after them.
|
||||
let registry = create_test_command_registry([test_signature()]);
|
||||
|
||||
let found_signature = registry
|
||||
.signature_from_line(
|
||||
"test --unknown-flag one foo ",
|
||||
TopLevelCommandCaseSensitivity::CaseSensitive,
|
||||
)
|
||||
.expect("test signature from line should exist");
|
||||
assert_eq!(found_signature.signature.name(), "one");
|
||||
assert_eq!(found_signature.token_index, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_flag_with_missing_value_at_end_of_input() {
|
||||
// When a flag that takes a required argument appears at the end of input
|
||||
// with no value provided, the resolved signature stays on the parent command.
|
||||
let registry = create_test_command_registry([test_signature()]);
|
||||
|
||||
let found_signature = registry
|
||||
.signature_from_line(
|
||||
"test --not-long ",
|
||||
TopLevelCommandCaseSensitivity::CaseSensitive,
|
||||
)
|
||||
.expect("test signature from line should exist");
|
||||
assert_eq!(found_signature.signature.name(), "test");
|
||||
assert_eq!(found_signature.token_index, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multiple_flags_with_values_before_subcommand() {
|
||||
// Multiple valued flags before a subcommand should all be skipped,
|
||||
// allowing the subcommand to be discovered.
|
||||
let registry = create_test_command_registry([test_signature()]);
|
||||
|
||||
// --not-long takes 1 required arg ("val"), -r is a switch.
|
||||
let found_signature = registry
|
||||
.signature_from_line(
|
||||
"test --not-long val -r one foo ",
|
||||
TopLevelCommandCaseSensitivity::CaseSensitive,
|
||||
)
|
||||
.expect("test signature from line should exist");
|
||||
assert_eq!(found_signature.signature.name(), "one");
|
||||
assert_eq!(found_signature.token_index, 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_only_flags_no_subcommand() {
|
||||
// When only flags appear after the command with no following subcommand,
|
||||
// the parent command should be returned.
|
||||
let registry = create_test_command_registry([test_signature()]);
|
||||
|
||||
let found_signature = registry
|
||||
.signature_from_line(
|
||||
"test --not-long val ",
|
||||
TopLevelCommandCaseSensitivity::CaseSensitive,
|
||||
)
|
||||
.expect("test signature from line should exist");
|
||||
assert_eq!(found_signature.signature.name(), "test");
|
||||
assert_eq!(found_signature.token_index, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_optional_flag_arg_does_not_consume_subcommand() {
|
||||
// --required-and-optional-args has 1 required arg + 1 optional arg.
|
||||
// The parser should only skip the required arg, so "one" is found as a
|
||||
// subcommand rather than being consumed as the optional arg.
|
||||
let registry = create_test_command_registry([test_signature()]);
|
||||
|
||||
let found_signature = registry
|
||||
.signature_from_line(
|
||||
"test --required-and-optional-args val one foo ",
|
||||
TopLevelCommandCaseSensitivity::CaseSensitive,
|
||||
)
|
||||
.expect("test signature from line should exist");
|
||||
assert_eq!(found_signature.signature.name(), "one");
|
||||
assert_eq!(found_signature.token_index, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_alias_expansion_path_skips_flag_with_value_before_subcommand() {
|
||||
// Exercises signature_with_alias_expansion (not just signature_from_tokens)
|
||||
// to ensure the alias-expansion code path also skips flags before subcommands.
|
||||
let kubectl = warp_command_signatures::signature_by_name("kubectl")
|
||||
.expect("global command signatures should include 'kubectl'");
|
||||
|
||||
let registry = create_test_command_registry([kubectl]);
|
||||
let ctx = FakeCompletionContext::new(registry).with_case_sensitivity();
|
||||
|
||||
let result = galaxyui::r#async::block_on(ctx.command_registry().signature_with_alias_expansion(
|
||||
&["kubectl", "-n", "default", "get"],
|
||||
true,
|
||||
&ctx,
|
||||
));
|
||||
let SignatureResult::Success(found_signature) = result else {
|
||||
panic!("expected SignatureResult::Success");
|
||||
};
|
||||
assert_eq!(found_signature.signature.name(), "get");
|
||||
assert_eq!(found_signature.token_index, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_alias_expansion_path_skips_multiple_flags_before_subcommand() {
|
||||
// Exercises signature_with_alias_expansion with multiple flags (valued and
|
||||
// switch) placed before the subcommand.
|
||||
let kubectl = warp_command_signatures::signature_by_name("kubectl")
|
||||
.expect("global command signatures should include 'kubectl'");
|
||||
|
||||
let registry = create_test_command_registry([kubectl]);
|
||||
let ctx = FakeCompletionContext::new(registry).with_case_sensitivity();
|
||||
|
||||
let result = galaxyui::r#async::block_on(ctx.command_registry().signature_with_alias_expansion(
|
||||
&[
|
||||
"kubectl",
|
||||
"--context",
|
||||
"staging-cluster",
|
||||
"-n",
|
||||
"project1",
|
||||
"get",
|
||||
],
|
||||
true,
|
||||
&ctx,
|
||||
));
|
||||
let SignatureResult::Success(found_signature) = result else {
|
||||
panic!("expected SignatureResult::Success");
|
||||
};
|
||||
assert_eq!(found_signature.signature.name(), "get");
|
||||
assert_eq!(found_signature.token_index, 5);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
#[cfg_attr(feature = "v2", path = "v2/mod.rs")]
|
||||
#[cfg_attr(not(feature = "v2"), path = "legacy/mod.rs")]
|
||||
mod imp;
|
||||
|
||||
pub use imp::*;
|
||||
|
||||
pub mod clap;
|
||||
|
||||
#[cfg(feature = "test-util")]
|
||||
pub mod testing;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,36 @@
|
||||
use super::CommandRegistry;
|
||||
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(feature = "v2")] {
|
||||
mod v2;
|
||||
pub use v2::*;
|
||||
|
||||
pub fn create_test_command_registry(
|
||||
signatures: impl IntoIterator<Item = super::CommandSignature>,
|
||||
) -> CommandRegistry {
|
||||
let registry = CommandRegistry::new();
|
||||
for signature in signatures.into_iter() {
|
||||
registry.register_signature(signature);
|
||||
}
|
||||
registry
|
||||
}
|
||||
} else if #[cfg(not(feature = "v2"))]{
|
||||
pub(crate) mod legacy;
|
||||
|
||||
pub use legacy::*;
|
||||
|
||||
pub fn create_test_command_registry(
|
||||
signatures: impl IntoIterator<Item = warp_command_signatures::Signature>,
|
||||
) -> CommandRegistry {
|
||||
use std::collections::HashMap;
|
||||
|
||||
let generators = HashMap::from([test_generators().into()]);
|
||||
CommandRegistry::new_for_test(signatures, generators)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) const TEST_GENERATOR_1_COMMAND: &str = "echo 1";
|
||||
pub(crate) const TEST_GENERATOR_2_COMMAND: &str = "echo 2";
|
||||
pub(crate) const TEST_ALIAS_COMMAND: &str = "echo alias";
|
||||
@@ -0,0 +1,666 @@
|
||||
//! V2 versions of command signatures used for testing.
|
||||
//!
|
||||
//! Each signature in this file should be semantically equivalent with a command signature returned
|
||||
//! by a function of the same name in `super::legacy`; this is to ensure that the same test
|
||||
//! coverage can run with the "v2" Cargo feature both enabled and disabled.
|
||||
use galaxy_js::TypedJsFunctionRef;
|
||||
|
||||
use crate::signatures::{
|
||||
Argument, ArgumentValue, Arity, Command, CommandSignature, GeneratorFn, GeneratorResults,
|
||||
GeneratorScript, Opt, Priority, Suggestion, TemplateType,
|
||||
};
|
||||
|
||||
use super::{TEST_GENERATOR_1_COMMAND, TEST_GENERATOR_2_COMMAND};
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
pub(crate) static ref TEST_GENERATOR_1_JS_FUNCTION: TypedJsFunctionRef<String, GeneratorResults> = TypedJsFunctionRef::<String, GeneratorResults>::new_for_test();
|
||||
pub(crate) static ref TEST_GENERATOR_2_JS_FUNCTION: TypedJsFunctionRef<String, GeneratorResults> = TypedJsFunctionRef::<String, GeneratorResults>::new_for_test();
|
||||
|
||||
static ref TEST_GENERATOR_1: ArgumentValue = ArgumentValue::Generator(GeneratorFn::ShellCommand {
|
||||
script: GeneratorScript::Static(TEST_GENERATOR_1_COMMAND.to_owned()),
|
||||
post_process: Some(TEST_GENERATOR_1_JS_FUNCTION.clone()),
|
||||
});
|
||||
|
||||
static ref TEST_GENERATOR_2: ArgumentValue = ArgumentValue::Generator(GeneratorFn::ShellCommand {
|
||||
script: GeneratorScript::Static(TEST_GENERATOR_2_COMMAND.to_owned()),
|
||||
post_process: Some(TEST_GENERATOR_2_JS_FUNCTION.clone()),
|
||||
});
|
||||
}
|
||||
|
||||
fn create_argument_value(name: impl Into<String>) -> ArgumentValue {
|
||||
ArgumentValue::Suggestion(Suggestion {
|
||||
value: name.into(),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn create_argument_value_with_priority(
|
||||
name: impl Into<String>,
|
||||
priority: Priority,
|
||||
) -> ArgumentValue {
|
||||
ArgumentValue::Suggestion(Suggestion {
|
||||
value: name.into(),
|
||||
priority,
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
// TODO(zachbai): Use this function to create hidden suggestions when hidden suggestions are
|
||||
// implemented in V2.
|
||||
#[allow(dead_code)]
|
||||
fn create_hidden_argument_suggestion(name: impl Into<String>) -> ArgumentValue {
|
||||
ArgumentValue::Suggestion(Suggestion {
|
||||
value: name.into(),
|
||||
display_value: None,
|
||||
description: None,
|
||||
priority: Priority::default(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn test_signature() -> CommandSignature {
|
||||
CommandSignature {
|
||||
command: Command {
|
||||
name: "test".to_owned(),
|
||||
alias: vec!["alias".to_owned()],
|
||||
description: Some("testing...".to_owned()),
|
||||
subcommands: vec![
|
||||
Command {
|
||||
name: "one".to_owned(),
|
||||
arguments: vec![
|
||||
Argument {
|
||||
name: "first arg".to_owned(),
|
||||
values: vec![
|
||||
create_argument_value("one-one"),
|
||||
create_argument_value("one-two"),
|
||||
],
|
||||
..Default::default()
|
||||
},
|
||||
Argument {
|
||||
name: "second arg".to_owned(),
|
||||
values: vec![
|
||||
create_argument_value("two-one"),
|
||||
create_argument_value("two-two"),
|
||||
],
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
priority: Priority::max(),
|
||||
..Default::default()
|
||||
},
|
||||
Command {
|
||||
name: "two".to_owned(),
|
||||
arguments: vec![
|
||||
Argument {
|
||||
name: "two-one".to_owned(),
|
||||
arity: Some(Arity {
|
||||
limit: None,
|
||||
delimiter: None,
|
||||
}),
|
||||
values: vec![create_argument_value("two-one")],
|
||||
..Default::default()
|
||||
},
|
||||
Argument {
|
||||
name: "two-two".to_owned(),
|
||||
values: vec![create_argument_value("two-two")],
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
priority: Priority::new(-50),
|
||||
..Default::default()
|
||||
},
|
||||
Command {
|
||||
name: "three".to_owned(),
|
||||
arguments: vec![
|
||||
Argument {
|
||||
name: "three-one".to_owned(),
|
||||
values: vec![create_argument_value("three-one")],
|
||||
..Default::default()
|
||||
},
|
||||
Argument {
|
||||
name: "three-two".to_owned(),
|
||||
values: vec![create_argument_value("three-two")],
|
||||
..Default::default()
|
||||
},
|
||||
Argument {
|
||||
name: "three-three".to_owned(),
|
||||
values: vec![create_argument_value("three-three")],
|
||||
optional: true,
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
priority: Priority::min(),
|
||||
..Default::default()
|
||||
},
|
||||
Command {
|
||||
name: "four".to_owned(),
|
||||
arguments: vec![
|
||||
Argument {
|
||||
name: "four-one".to_owned(),
|
||||
values: vec![create_argument_value("four-one")],
|
||||
..Default::default()
|
||||
},
|
||||
Argument {
|
||||
name: "four-two".to_owned(),
|
||||
arity: Some(Arity {
|
||||
limit: None,
|
||||
delimiter: None,
|
||||
}),
|
||||
values: vec![create_argument_value("four-two")],
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
..Default::default()
|
||||
},
|
||||
Command {
|
||||
name: "five".to_owned(),
|
||||
arguments: vec![Argument {
|
||||
name: "five".to_owned(),
|
||||
values: vec![TEST_GENERATOR_1.clone(), TEST_GENERATOR_2.clone()],
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
Command {
|
||||
name: "six".to_owned(),
|
||||
arguments: vec![Argument {
|
||||
name: "six-one".to_owned(),
|
||||
values: vec![
|
||||
create_argument_value("six-arg"),
|
||||
create_argument_value_with_priority("six-arg-2", Priority::max()),
|
||||
],
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
Command {
|
||||
name: "seven".to_owned(),
|
||||
arguments: vec![Argument {
|
||||
name: "seven-arg".to_owned(),
|
||||
values: vec![TEST_GENERATOR_2.clone(), TEST_GENERATOR_2.clone()],
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
Command {
|
||||
name: "eight".to_owned(),
|
||||
subcommands: vec![Command {
|
||||
name: "eight-subcommand".to_owned(),
|
||||
..Default::default()
|
||||
}],
|
||||
arguments: vec![
|
||||
Argument {
|
||||
name: "eight-arg".to_owned(),
|
||||
values: vec![create_argument_value("eight-arg")],
|
||||
..Default::default()
|
||||
},
|
||||
Argument {
|
||||
name: "eight-arg-2".to_owned(),
|
||||
arity: Some(Arity {
|
||||
limit: None,
|
||||
delimiter: None,
|
||||
}),
|
||||
values: vec![create_argument_value("eight-arg-2")],
|
||||
optional: true,
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
..Default::default()
|
||||
},
|
||||
Command {
|
||||
name: "nine".to_owned(),
|
||||
arguments: vec![Argument {
|
||||
name: "nine-arg".to_owned(),
|
||||
values: vec![create_argument_value("git")],
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
options: vec![
|
||||
Opt {
|
||||
name: vec!["--long".to_owned()],
|
||||
arguments: vec![Argument {
|
||||
name: "long-one".to_owned(),
|
||||
arity: Some(Arity {
|
||||
limit: None,
|
||||
delimiter: None,
|
||||
}),
|
||||
values: vec![
|
||||
create_argument_value("long-one"),
|
||||
create_argument_value("long-two"),
|
||||
],
|
||||
..Default::default()
|
||||
}],
|
||||
priority: Priority::min(),
|
||||
..Default::default()
|
||||
},
|
||||
Opt {
|
||||
name: vec!["--not-long".to_owned()],
|
||||
arguments: vec![Argument {
|
||||
name: "long-one".to_owned(),
|
||||
values: vec![
|
||||
create_argument_value("not-long-one"),
|
||||
create_argument_value("not-long-two"),
|
||||
],
|
||||
..Default::default()
|
||||
}],
|
||||
priority: Priority::max(),
|
||||
..Default::default()
|
||||
},
|
||||
Opt {
|
||||
name: vec!["--required-args".to_owned()],
|
||||
arguments: vec![
|
||||
Argument {
|
||||
name: "required-arg-1".to_owned(),
|
||||
values: vec![
|
||||
create_argument_value("arg-1-1"),
|
||||
create_argument_value("arg-1-2"),
|
||||
],
|
||||
..Default::default()
|
||||
},
|
||||
Argument {
|
||||
name: "required-arg-2".to_owned(),
|
||||
values: vec![
|
||||
create_argument_value("arg-2-1"),
|
||||
create_argument_value("arg-2-2"),
|
||||
],
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
priority: Priority::max(),
|
||||
..Default::default()
|
||||
},
|
||||
Opt {
|
||||
name: vec!["--required-args-with-var".to_owned()],
|
||||
arguments: vec![
|
||||
Argument {
|
||||
name: "required-arg".to_owned(),
|
||||
values: vec![
|
||||
create_argument_value("arg-1"),
|
||||
create_argument_value("arg-2"),
|
||||
],
|
||||
..Default::default()
|
||||
},
|
||||
Argument {
|
||||
name: "variadic-arg".to_owned(),
|
||||
arity: Some(Arity {
|
||||
limit: None,
|
||||
delimiter: None,
|
||||
}),
|
||||
values: vec![
|
||||
create_argument_value("vararg-1"),
|
||||
create_argument_value("vararg-2"),
|
||||
],
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
priority: Priority::min(),
|
||||
..Default::default()
|
||||
},
|
||||
Opt {
|
||||
name: vec!["--required-and-optional-args".to_owned()],
|
||||
arguments: vec![
|
||||
Argument {
|
||||
name: "required-arg".to_owned(),
|
||||
values: vec![
|
||||
create_argument_value("required-1"),
|
||||
create_argument_value("required-2"),
|
||||
],
|
||||
..Default::default()
|
||||
},
|
||||
Argument {
|
||||
name: "optional-arg".to_owned(),
|
||||
values: vec![
|
||||
create_argument_value("optional-1"),
|
||||
create_argument_value("optional-2"),
|
||||
],
|
||||
optional: true,
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
required: false,
|
||||
priority: Priority::default(),
|
||||
..Default::default()
|
||||
},
|
||||
Opt {
|
||||
name: vec!["--template-args-for-opt".to_owned()],
|
||||
arguments: vec![Argument {
|
||||
name: "templated".to_owned(),
|
||||
values: vec![ArgumentValue::Template {
|
||||
type_name: TemplateType::FilesAndFolders,
|
||||
filter_name: None,
|
||||
}],
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
Opt {
|
||||
name: vec!["-r".to_owned()],
|
||||
..Default::default()
|
||||
},
|
||||
Opt {
|
||||
name: vec!["-V".to_owned()],
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
..Default::default()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cd_signature() -> CommandSignature {
|
||||
CommandSignature {
|
||||
command: Command {
|
||||
name: "cd".to_owned(),
|
||||
alias: vec![],
|
||||
description: Some("testing...".to_owned()),
|
||||
arguments: vec![Argument {
|
||||
name: "directories".to_owned(),
|
||||
description: None,
|
||||
arity: None,
|
||||
values: vec![
|
||||
// TODO(completions-v2): Uncomment when "hidden" suggestions are implemented.
|
||||
// A "hidden" suggestion is only shown if it is an exact match for the current
|
||||
// token. In this case, "-" is only shown as a suggestion if the user has
|
||||
// exactly typed "cd -" in the input.
|
||||
// create_hidden_argument_suggestion('-'),
|
||||
ArgumentValue::Template {
|
||||
type_name: TemplateType::Folders,
|
||||
filter_name: None,
|
||||
},
|
||||
],
|
||||
optional: false,
|
||||
}],
|
||||
subcommands: vec![],
|
||||
options: vec![],
|
||||
priority: Priority::default(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ls_signature() -> CommandSignature {
|
||||
CommandSignature {
|
||||
command: Command {
|
||||
name: "ls".to_owned(),
|
||||
alias: vec![],
|
||||
description: Some("testing...".to_owned()),
|
||||
arguments: vec![Argument {
|
||||
name: "filepaths".to_owned(),
|
||||
description: None,
|
||||
arity: Some(Arity {
|
||||
limit: None,
|
||||
delimiter: None,
|
||||
}),
|
||||
values: vec![ArgumentValue::Template {
|
||||
type_name: TemplateType::FilesAndFolders,
|
||||
filter_name: None,
|
||||
}],
|
||||
optional: true,
|
||||
}],
|
||||
subcommands: vec![],
|
||||
options: vec![
|
||||
Opt {
|
||||
name: vec!["-a".to_owned()],
|
||||
..Default::default()
|
||||
},
|
||||
Opt {
|
||||
name: vec!["--color".to_owned()],
|
||||
arguments: vec![Argument {
|
||||
name: "when".to_owned(),
|
||||
values: vec![
|
||||
create_argument_value("force"),
|
||||
create_argument_value("auto"),
|
||||
create_argument_value("never"),
|
||||
],
|
||||
optional: true,
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
Opt {
|
||||
name: vec!["--test".to_owned()],
|
||||
arguments: vec![Argument {
|
||||
name: "when".to_owned(),
|
||||
arity: Some(Arity {
|
||||
limit: None,
|
||||
delimiter: None,
|
||||
}),
|
||||
values: vec![
|
||||
create_argument_value("force"),
|
||||
create_argument_value("auto"),
|
||||
create_argument_value("never"),
|
||||
],
|
||||
optional: true,
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
priority: Priority::default(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// A signature with a single positional that has no argument types.
|
||||
pub fn signature_with_empty_positional() -> CommandSignature {
|
||||
CommandSignature {
|
||||
command: Command {
|
||||
name: "test-empty".to_owned(),
|
||||
alias: vec![],
|
||||
description: Some("testing...".to_owned()),
|
||||
arguments: vec![Argument {
|
||||
name: "test-empty--arg".to_owned(),
|
||||
description: None,
|
||||
arity: None,
|
||||
values: vec![],
|
||||
optional: false,
|
||||
}],
|
||||
subcommands: vec![],
|
||||
options: vec![],
|
||||
priority: Priority::default(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn git_signature() -> CommandSignature {
|
||||
CommandSignature {
|
||||
command: Command {
|
||||
name: "git".to_owned(),
|
||||
description: Some("the stupid content tracker".to_owned()),
|
||||
subcommands: vec![
|
||||
Command {
|
||||
name: "add".to_owned(),
|
||||
description: Some("Add file contents to the index".to_owned()),
|
||||
..Default::default()
|
||||
},
|
||||
Command {
|
||||
name: "checkout".to_owned(),
|
||||
description: Some("Switch branches or restore working tree files".to_owned()),
|
||||
arguments: vec![Argument {
|
||||
name: "branch".to_owned(),
|
||||
description: Some("Branch".to_owned()),
|
||||
values: vec![
|
||||
create_argument_value("漢字"),
|
||||
create_argument_value("bob/卡b卡"),
|
||||
],
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
Command {
|
||||
name: "clone".to_owned(),
|
||||
description: Some("Clone a repository into a new directory".into()),
|
||||
..Default::default()
|
||||
},
|
||||
Command {
|
||||
name: "branch".to_owned(),
|
||||
description: Some("List, create, or delete branches".into()),
|
||||
options: vec![
|
||||
Opt {
|
||||
name: vec!["--delete".to_owned()],
|
||||
arguments: vec![Argument {
|
||||
name: "branch".to_owned(),
|
||||
arity: Some(Arity {
|
||||
limit: None,
|
||||
delimiter: None,
|
||||
}),
|
||||
values: vec![
|
||||
create_argument_value("branch-1"),
|
||||
create_argument_value("second-branch"),
|
||||
],
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
Opt {
|
||||
name: vec!["-m".to_owned()],
|
||||
arguments: vec![
|
||||
Argument {
|
||||
name: "from_branch".to_owned(),
|
||||
values: vec![
|
||||
create_argument_value("branch-1"),
|
||||
create_argument_value("second-branch"),
|
||||
],
|
||||
..Default::default()
|
||||
},
|
||||
Argument {
|
||||
name: "to_branch".to_owned(),
|
||||
values: vec![
|
||||
create_argument_value("branch-1"),
|
||||
create_argument_value("second-branch"),
|
||||
],
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
options: vec![
|
||||
Opt {
|
||||
name: vec!["-p".to_string()],
|
||||
..Default::default()
|
||||
},
|
||||
Opt {
|
||||
name: vec!["--version".to_string()],
|
||||
..Default::default()
|
||||
},
|
||||
Opt {
|
||||
name: vec!["--help".to_string()],
|
||||
..Default::default()
|
||||
},
|
||||
Opt {
|
||||
name: vec!["--bare".to_string()],
|
||||
..Default::default()
|
||||
},
|
||||
Opt {
|
||||
name: vec!["-c".to_string()],
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
..Default::default()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn java_signature() -> CommandSignature {
|
||||
CommandSignature {
|
||||
command: Command {
|
||||
name: "java".to_string(),
|
||||
description: Some("Launch a java application".into()),
|
||||
arguments: vec![Argument {
|
||||
name: "<mainclass>".to_string(),
|
||||
optional: true,
|
||||
..Default::default()
|
||||
}],
|
||||
options: vec![
|
||||
Opt {
|
||||
// Java supports both styles of long-hand options.
|
||||
name: vec!["-version".to_string(), "--version".to_string()],
|
||||
description: Some("print product version to the error stream and exit".into()),
|
||||
..Default::default()
|
||||
},
|
||||
Opt {
|
||||
name: vec![
|
||||
"-cp".to_string(),
|
||||
"-classpath".to_string(),
|
||||
"--class-path".to_string(),
|
||||
],
|
||||
description: Some(
|
||||
"class search path of directories and zip/jar files".to_string(),
|
||||
),
|
||||
arguments: vec![Argument {
|
||||
name: "classpath".to_string(),
|
||||
..Default::default()
|
||||
}],
|
||||
required: false,
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
..Default::default()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn fuzzy_signature() -> CommandSignature {
|
||||
CommandSignature {
|
||||
command: Command {
|
||||
name: "fuzzy".to_owned(),
|
||||
description: Some("testing...".to_owned()),
|
||||
arguments: vec![],
|
||||
subcommands: vec![
|
||||
Command {
|
||||
name: "prefix1".to_owned(),
|
||||
..Default::default()
|
||||
},
|
||||
Command {
|
||||
name: "prefix2".to_owned(),
|
||||
priority: Priority::max(),
|
||||
..Default::default()
|
||||
},
|
||||
Command {
|
||||
name: "suffix-pre-fix".to_owned(),
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
options: vec![Opt {
|
||||
name: vec!["--pre-fx".to_owned()],
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn npm_signature() -> CommandSignature {
|
||||
CommandSignature {
|
||||
command: Command {
|
||||
name: "npm".to_owned(),
|
||||
description: Some("testing...".to_owned()),
|
||||
subcommands: vec![
|
||||
Command {
|
||||
name: "r".to_owned(),
|
||||
arguments: vec![Argument {
|
||||
name: "r-arg".to_owned(),
|
||||
values: vec![create_argument_value("r-arg")],
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
Command {
|
||||
name: "run".to_owned(),
|
||||
arguments: vec![Argument {
|
||||
name: "run-arg".to_owned(),
|
||||
values: vec![create_argument_value("run-arg")],
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
options: vec![],
|
||||
..Default::default()
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
//! Contains `FromWarpJs` trait implementations for converting JavaScript command signatures to
|
||||
//! `galaxy_completer::signatures::CommandSignature`s, as well as `IntoWarpJs` implementations for
|
||||
//! Rust structs that may be passed to JS functions defined on the Command Signature (e.g.
|
||||
//! `GeneratorCompletionContext`).
|
||||
use rquickjs::{FromJs, Function, Object, Value};
|
||||
use galaxy_js::{
|
||||
util::{get_one_or_more_optional, get_one_or_more_required, get_optional, get_required},
|
||||
FromWarpJs, IntoWarpJs, JsFunctionRegistry,
|
||||
};
|
||||
|
||||
use super::{
|
||||
Argument, ArgumentValue, Command, CommandSignature, GeneratorCompletionContext, GeneratorFn,
|
||||
GeneratorResults, GeneratorScript, Opt, Priority, Suggestion, TemplateType,
|
||||
};
|
||||
|
||||
impl<'js> FromWarpJs<'js> for CommandSignature {
|
||||
fn from_galaxy_js(
|
||||
ctx: rquickjs::Ctx<'js>,
|
||||
value: rquickjs::Value<'js>,
|
||||
js_function_registry: &mut JsFunctionRegistry,
|
||||
) -> rquickjs::Result<Self> {
|
||||
let object = Object::from_value(value)?;
|
||||
let command = Command::from_galaxy_js(ctx, object.get("command")?, js_function_registry)?;
|
||||
Ok(Self { command })
|
||||
}
|
||||
}
|
||||
|
||||
impl<'js> FromWarpJs<'js> for Command {
|
||||
fn from_galaxy_js(
|
||||
ctx: rquickjs::Ctx<'js>,
|
||||
value: rquickjs::Value<'js>,
|
||||
js_function_registry: &mut JsFunctionRegistry,
|
||||
) -> rquickjs::Result<Self> {
|
||||
let object = Object::from_value(value)?;
|
||||
let name: String = get_required(&object, "name", js_function_registry, ctx)?;
|
||||
let alias: Vec<String> =
|
||||
get_one_or_more_optional(&object, "alias", js_function_registry, ctx)?;
|
||||
let description: Option<String> =
|
||||
get_optional(&object, "description", js_function_registry, ctx)?;
|
||||
let arguments: Vec<Argument> =
|
||||
get_one_or_more_optional(&object, "arguments", js_function_registry, ctx)?;
|
||||
let subcommands: Vec<Command> =
|
||||
get_one_or_more_optional(&object, "subcommands", js_function_registry, ctx)?;
|
||||
let options: Vec<Opt> =
|
||||
get_one_or_more_optional(&object, "options", js_function_registry, ctx)?;
|
||||
let priority: Option<i32> = get_optional(&object, "priority", js_function_registry, ctx)?;
|
||||
|
||||
Ok(Command {
|
||||
name,
|
||||
alias,
|
||||
description,
|
||||
arguments,
|
||||
subcommands,
|
||||
options,
|
||||
priority: priority.map(Priority::new).unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl<'js> FromWarpJs<'js> for Argument {
|
||||
fn from_galaxy_js(
|
||||
ctx: rquickjs::Ctx<'js>,
|
||||
value: rquickjs::Value<'js>,
|
||||
js_function_registry: &mut JsFunctionRegistry,
|
||||
) -> rquickjs::Result<Self> {
|
||||
let object = Object::from_value(value)?;
|
||||
let name: String = get_required(&object, "name", js_function_registry, ctx)?;
|
||||
let description: Option<String> =
|
||||
get_optional(&object, "description", js_function_registry, ctx)?;
|
||||
let values: Vec<ArgumentValue> =
|
||||
get_one_or_more_optional(&object, "values", js_function_registry, ctx)?;
|
||||
let optional: bool =
|
||||
get_optional(&object, "optional", js_function_registry, ctx)?.unwrap_or(false);
|
||||
|
||||
Ok(Argument {
|
||||
name,
|
||||
description,
|
||||
values,
|
||||
optional,
|
||||
arity: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl<'js> FromWarpJs<'js> for ArgumentValue {
|
||||
fn from_galaxy_js(
|
||||
ctx: rquickjs::Ctx<'js>,
|
||||
value: rquickjs::Value<'js>,
|
||||
js_function_registry: &mut JsFunctionRegistry,
|
||||
) -> rquickjs::Result<Self> {
|
||||
// TODO(zachbai): Implement conversion + Rust representation of ArgumentValue.RootCommand
|
||||
// (see typescript schema in command-signature.d.ts).
|
||||
if value.is_object() {
|
||||
let object = Object::from_value(value)?;
|
||||
if object.contains_key("value")? {
|
||||
Ok(ArgumentValue::Suggestion(Suggestion::from_galaxy_js(
|
||||
ctx,
|
||||
object.into_value(),
|
||||
js_function_registry,
|
||||
)?))
|
||||
} else if let Some(type_name) =
|
||||
get_optional::<TemplateType>(&object, "typeName", js_function_registry, ctx)?
|
||||
{
|
||||
let filter_name: Option<String> =
|
||||
get_optional(&object, "filterName", js_function_registry, ctx)?;
|
||||
Ok(ArgumentValue::Template {
|
||||
type_name,
|
||||
filter_name,
|
||||
})
|
||||
} else if let Some(generate_suggestions_fn) = get_optional::<GeneratorFn>(
|
||||
&object,
|
||||
"generateSuggestionsFn",
|
||||
js_function_registry,
|
||||
ctx,
|
||||
)? {
|
||||
Ok(ArgumentValue::Generator(generate_suggestions_fn))
|
||||
} else {
|
||||
Err(rquickjs::Error::FromJs {
|
||||
from: "object",
|
||||
to: "ArgumentValue",
|
||||
message: None,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
Err(rquickjs::Error::FromJs {
|
||||
from: "object",
|
||||
to: "ArgumentValue",
|
||||
message: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'js> FromWarpJs<'js> for Suggestion {
|
||||
fn from_galaxy_js(
|
||||
ctx: rquickjs::Ctx<'js>,
|
||||
value: rquickjs::Value<'js>,
|
||||
js_function_registry: &mut JsFunctionRegistry,
|
||||
) -> rquickjs::Result<Self> {
|
||||
let object = Object::from_value(value)?;
|
||||
let value: String = get_required(&object, "value", js_function_registry, ctx)?;
|
||||
let display_value: Option<String> =
|
||||
get_optional(&object, "displayValue", js_function_registry, ctx)?;
|
||||
let description: Option<String> =
|
||||
get_optional(&object, "description", js_function_registry, ctx)?;
|
||||
let priority: Option<i32> = get_optional(&object, "priority", js_function_registry, ctx)?;
|
||||
|
||||
Ok(Suggestion {
|
||||
value,
|
||||
display_value,
|
||||
description,
|
||||
priority: priority.map(Priority::new).unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl<'js> FromWarpJs<'js> for TemplateType {
|
||||
fn from_galaxy_js(
|
||||
ctx: rquickjs::Ctx<'js>,
|
||||
value: rquickjs::Value<'js>,
|
||||
js_function_registry: &mut JsFunctionRegistry,
|
||||
) -> rquickjs::Result<Self> {
|
||||
let type_string = String::from_galaxy_js(ctx, value, js_function_registry)?;
|
||||
match type_string.as_str() {
|
||||
"TemplateType.Files" => Ok(TemplateType::Files),
|
||||
"TemplateType.Folders" => Ok(TemplateType::Folders),
|
||||
"TemplateType.FilesAndFolders" => Ok(TemplateType::FilesAndFolders),
|
||||
_ => Err(rquickjs::Error::FromJs {
|
||||
from: "string",
|
||||
to: "TemplateType",
|
||||
message: None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'js> FromWarpJs<'js> for GeneratorFn {
|
||||
fn from_galaxy_js(
|
||||
ctx: rquickjs::Ctx<'js>,
|
||||
value: rquickjs::Value<'js>,
|
||||
js_function_registry: &mut JsFunctionRegistry,
|
||||
) -> rquickjs::Result<Self> {
|
||||
if value.is_object() {
|
||||
let object = Object::from_value(value)?;
|
||||
let value: Value = object.get("script")?;
|
||||
let script = GeneratorScript::from_galaxy_js(ctx, value, js_function_registry)?;
|
||||
let post_process = if object.contains_key("postProcess")? {
|
||||
let function: Function = object.get("postProcess")?;
|
||||
let function_ref = js_function_registry
|
||||
.register_js_function::<String, GeneratorResults>(function, ctx);
|
||||
Some(function_ref)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok(GeneratorFn::ShellCommand {
|
||||
script,
|
||||
post_process,
|
||||
})
|
||||
} else if value.is_function() {
|
||||
let function: Function = Function::from_value(value)?;
|
||||
let function_ref = js_function_registry
|
||||
.register_js_function::<GeneratorCompletionContext, GeneratorResults>(
|
||||
function, ctx,
|
||||
);
|
||||
Ok(GeneratorFn::Custom(function_ref))
|
||||
} else {
|
||||
Err(rquickjs::Error::FromJs {
|
||||
from: "generator_fn",
|
||||
to: "GeneratorFn",
|
||||
message: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'js> FromWarpJs<'js> for GeneratorScript {
|
||||
fn from_galaxy_js(
|
||||
ctx: rquickjs::Ctx<'js>,
|
||||
value: rquickjs::Value<'js>,
|
||||
js_function_registry: &mut JsFunctionRegistry,
|
||||
) -> rquickjs::Result<Self> {
|
||||
if value.is_string() {
|
||||
Ok(GeneratorScript::Static(String::from_js(ctx, value)?))
|
||||
} else if value.is_function() {
|
||||
let script_fn: Function = Function::from_value(value)?;
|
||||
let function_ref =
|
||||
js_function_registry.register_js_function::<Vec<String>, String>(script_fn, ctx);
|
||||
Ok(GeneratorScript::Dynamic(function_ref))
|
||||
} else {
|
||||
Err(rquickjs::Error::FromJs {
|
||||
from: "script",
|
||||
to: "GeneratorScript",
|
||||
message: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'js> FromWarpJs<'js> for Opt {
|
||||
fn from_galaxy_js(
|
||||
ctx: rquickjs::Ctx<'js>,
|
||||
value: rquickjs::Value<'js>,
|
||||
js_function_registry: &mut JsFunctionRegistry,
|
||||
) -> rquickjs::Result<Self> {
|
||||
let object = Object::from_value(value)?;
|
||||
let name: Vec<String> =
|
||||
get_one_or_more_required(&object, "name", js_function_registry, ctx)?;
|
||||
let description: Option<String> =
|
||||
get_optional(&object, "description", js_function_registry, ctx)?;
|
||||
let required: bool =
|
||||
get_optional(&object, "required", js_function_registry, ctx)?.unwrap_or(false);
|
||||
let arguments: Vec<Argument> =
|
||||
get_one_or_more_optional(&object, "arguments", js_function_registry, ctx)?;
|
||||
let priority: Option<i32> = get_optional(&object, "priority", js_function_registry, ctx)?;
|
||||
|
||||
Ok(Opt {
|
||||
name,
|
||||
description,
|
||||
arguments,
|
||||
required,
|
||||
priority: priority.map(Priority::new).unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl<'js> FromWarpJs<'js> for GeneratorResults {
|
||||
fn from_galaxy_js(
|
||||
ctx: rquickjs::Ctx<'js>,
|
||||
value: rquickjs::Value<'js>,
|
||||
js_function_registry: &mut JsFunctionRegistry,
|
||||
) -> rquickjs::Result<Self> {
|
||||
let object = Object::from_value(value)?;
|
||||
let suggestions =
|
||||
get_one_or_more_required(&object, "suggestions", js_function_registry, ctx)?;
|
||||
let is_ordered =
|
||||
get_optional(&object, "is_ordered", js_function_registry, ctx)?.unwrap_or(false);
|
||||
|
||||
Ok(GeneratorResults {
|
||||
suggestions,
|
||||
is_ordered,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl<'js> IntoWarpJs<'js> for GeneratorCompletionContext {
|
||||
fn into_galaxy_js(self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
|
||||
let object = Object::new(ctx)?;
|
||||
object.set("tokens", self.tokens)?;
|
||||
object.set("pwd", self.pwd)?;
|
||||
Ok(object.into_value())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
//! This module contains functions for looking up a matching command signature from tokenized and
|
||||
//! untokenized input.
|
||||
use itertools::Itertools;
|
||||
|
||||
use super::{registry::CommandRegistry, Command};
|
||||
|
||||
/// Returns the highest-precedence matching `Command` signature object for the given `input`, if
|
||||
/// any, along with the index of the token in `input` matched to the returned `Command`.
|
||||
///
|
||||
/// Subcommands take precedence over parent commands.
|
||||
///
|
||||
/// Note that a token in the input must have trailing whitespace (e.g. marking it as "completed")
|
||||
/// to be eligible to be matched to a command signature. So, for example, if the input does not
|
||||
/// contain trailing whitespace, the last token is not considered in the matching algorithm.
|
||||
/// Otherwise, if one subcommand is a prefix of another subcommand, we could mistakenly eagerly
|
||||
/// return the signature for the shorter subcommand even if the intent was to continue typing to
|
||||
/// enter the longer subcommand.
|
||||
///
|
||||
/// Practically, this means that for input "test_command test_subcommand", even if there is a
|
||||
/// subcommand signature for "test_subcommand", this returns the signature for "test_command",
|
||||
/// because it's assumed "test_subcommand" may still be edited.
|
||||
pub fn get_matching_signature_for_input<'a>(
|
||||
input: &str,
|
||||
registry: &'a CommandRegistry,
|
||||
) -> Option<(&'a Command, usize)> {
|
||||
let input_tokens = input.split_whitespace().collect_vec();
|
||||
get_matching_signature_for_tokenized_input(
|
||||
&input_tokens,
|
||||
input.ends_with(char::is_whitespace),
|
||||
registry,
|
||||
)
|
||||
}
|
||||
|
||||
/// Returns the highest-precedence matching `Command` signature object for the given tokenized
|
||||
/// `input`, if any. This is equivalent to `get_matching_signature_input` above, except input is
|
||||
/// tokenized (e.g. given as an array of string tokens, which were assumed to be space-delimited in
|
||||
/// the original input). Because input is tokenized, the caller needs to explicitly specify whether
|
||||
/// the original input had trailing whitespace to determine if the last token is eligible for use
|
||||
/// in the matching algorithm.
|
||||
///
|
||||
/// See comments on `get_matching_signature_input` for more details.
|
||||
pub fn get_matching_signature_for_tokenized_input<'a>(
|
||||
input_tokens: &[&str],
|
||||
has_trailing_whitespace: bool,
|
||||
registry: &'a CommandRegistry,
|
||||
) -> Option<(&'a Command, usize)> {
|
||||
let (first_token, remaining_tokens) = input_tokens.split_first()?;
|
||||
|
||||
// Find the top level signature.
|
||||
registry.get_signature(first_token).map(|signature| {
|
||||
deepest_matching_subcommand_signature(
|
||||
remaining_tokens,
|
||||
&signature.command,
|
||||
0,
|
||||
has_trailing_whitespace,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Given a parent `command_signature`, resolves the most specific (deepest) subcommand that
|
||||
/// the user has entered in `input_tokens`, skipping over any flags that appear
|
||||
/// before the subcommand name (e.g. `kubectl -n kube-system get` resolves to `get`).
|
||||
///
|
||||
/// Returns the matched `Command` along with the index of its token in `input_tokens`.
|
||||
/// If no subcommand is found, `command_signature` itself is returned at `current_token_index`.
|
||||
///
|
||||
/// The last token is only eligible for a subcommand match when `has_trailing_whitespace` is
|
||||
/// true, i.e. the user has finished typing it.
|
||||
fn deepest_matching_subcommand_signature<'a>(
|
||||
input_tokens: &[&str],
|
||||
command_signature: &'a Command,
|
||||
mut current_token_index: usize,
|
||||
has_trailing_whitespace: bool,
|
||||
) -> (&'a Command, usize) {
|
||||
if input_tokens.is_empty() {
|
||||
return (command_signature, current_token_index);
|
||||
}
|
||||
|
||||
// Save the starting index before we begin scanning for subcommands.
|
||||
// If we skip past flags but never find a subcommand beyond them, we
|
||||
// return this index so that `parse_internal_command` treats the flags
|
||||
// as arguments to be parsed rather than swallowing them into the
|
||||
// command name.
|
||||
let subcommand_search_start_index = current_token_index;
|
||||
|
||||
while current_token_index < input_tokens.len() {
|
||||
let is_last_token = current_token_index == input_tokens.len() - 1;
|
||||
let token = input_tokens[current_token_index];
|
||||
|
||||
// Try to match the token against a subcommand.
|
||||
let subcommand_match = command_signature.subcommands.iter().find(|subcommand| {
|
||||
let token_matches_subcommand = token == subcommand.name.as_str();
|
||||
if is_last_token {
|
||||
// If this is the last token, treat the subcommand signature as a match
|
||||
// if there is trailing whitespace, which affirms the user's intent to use
|
||||
// that subcommand. If there is no trailing whitespace, the user may still
|
||||
// be in the process of editing that subcommand (or specifying a different
|
||||
// subcommand of which the current token is a prefix).
|
||||
token_matches_subcommand && has_trailing_whitespace
|
||||
} else {
|
||||
token_matches_subcommand
|
||||
}
|
||||
});
|
||||
|
||||
if let Some(subcommand) = subcommand_match {
|
||||
return deepest_matching_subcommand_signature(
|
||||
input_tokens,
|
||||
subcommand,
|
||||
current_token_index + 1,
|
||||
has_trailing_whitespace,
|
||||
);
|
||||
}
|
||||
|
||||
// If the token is a flag (starts with '-'), try to skip past it and its arguments
|
||||
// to continue looking for subcommands. This handles cases like
|
||||
// `kubectl -n kube-system get pods` where flags appear before subcommands.
|
||||
if token.starts_with('-') {
|
||||
if let Some(option) = command_signature
|
||||
.options
|
||||
.iter()
|
||||
.find(|opt| opt.name.iter().any(|name| name == token))
|
||||
{
|
||||
// Skip the flag's arguments (non-switch options consume the next token(s)).
|
||||
// Clamp to the number of argument tokens actually present to avoid
|
||||
// advancing past the end of input_tokens (e.g. `kubectl -n ` with no
|
||||
// namespace value).
|
||||
let num_args = option.arguments.iter().filter(|arg| !arg.optional).count();
|
||||
let available = input_tokens.len().saturating_sub(current_token_index + 1);
|
||||
current_token_index += num_args.min(available);
|
||||
}
|
||||
// Advance past the flag token itself.
|
||||
current_token_index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Token is not a subcommand or a recognized flag; stop searching.
|
||||
break;
|
||||
}
|
||||
|
||||
// No subcommand was found beyond any skipped flags, so return the
|
||||
// start index. This ensures the caller's parser still sees those
|
||||
// flag tokens and can process them as flag arguments.
|
||||
(command_signature, subcommand_search_start_index)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "lookup_test.rs"]
|
||||
mod test;
|
||||
@@ -0,0 +1,418 @@
|
||||
use crate::signatures::{Argument, Command, CommandSignature, Opt};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Creates a `test_command` signature with a `test_subcommand` subcommand
|
||||
/// and the given options on the root command.
|
||||
fn test_command_signature_with_options(options: Vec<Opt>) -> CommandSignature {
|
||||
CommandSignature {
|
||||
command: Command {
|
||||
name: "test_command".to_owned(),
|
||||
subcommands: vec![Command {
|
||||
name: "test_subcommand".to_owned(),
|
||||
..Default::default()
|
||||
}],
|
||||
options,
|
||||
..Default::default()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn valued_option() -> Opt {
|
||||
Opt {
|
||||
name: vec!["-n".to_owned(), "--name".to_owned()],
|
||||
arguments: vec![Argument {
|
||||
name: "value".to_owned(),
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_matching_signature_for_input_on_root_command() {
|
||||
let registry = CommandRegistry::new();
|
||||
registry.register_signature(CommandSignature {
|
||||
command: Command {
|
||||
name: "test_command".to_owned(),
|
||||
..Default::default()
|
||||
},
|
||||
});
|
||||
|
||||
let (found_signature, index) = get_matching_signature_for_input("test_command ", ®istry)
|
||||
.expect("Signature should exist");
|
||||
assert_eq!(found_signature.name, "test_command");
|
||||
assert_eq!(index, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_matching_signature_for_input_on_root_command_with_argument() {
|
||||
let registry = CommandRegistry::new();
|
||||
registry.register_signature(CommandSignature {
|
||||
command: Command {
|
||||
name: "test_command".to_owned(),
|
||||
subcommands: vec![Command {
|
||||
name: "test_subcommand".to_owned(),
|
||||
..Default::default()
|
||||
}],
|
||||
arguments: vec![Argument {
|
||||
name: "arg1".to_owned(),
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
});
|
||||
|
||||
let (found_signature, index) =
|
||||
get_matching_signature_for_input("test_command some_arg_value ", ®istry)
|
||||
.expect("Signature should be found.");
|
||||
assert_eq!(found_signature.name, "test_command");
|
||||
assert_eq!(index, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_matching_signature_for_input_on_subcommand() {
|
||||
let registry = CommandRegistry::new();
|
||||
registry.register_signature(CommandSignature {
|
||||
command: Command {
|
||||
name: "test_command".to_owned(),
|
||||
subcommands: vec![Command {
|
||||
name: "test_subcommand".to_owned(),
|
||||
..Default::default()
|
||||
}],
|
||||
arguments: vec![Argument {
|
||||
name: "arg1".to_owned(),
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
});
|
||||
|
||||
let (found_signature, index) =
|
||||
get_matching_signature_for_input("test_command test_subcommand ", ®istry)
|
||||
.expect("Signature should be found.");
|
||||
assert_eq!(found_signature.name, "test_subcommand");
|
||||
assert_eq!(index, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_matching_signature_for_input_on_subcommand_with_argument() {
|
||||
let registry = CommandRegistry::new();
|
||||
registry.register_signature(CommandSignature {
|
||||
command: Command {
|
||||
name: "test_command".to_owned(),
|
||||
subcommands: vec![
|
||||
Command {
|
||||
name: "test_subcommand1".to_owned(),
|
||||
arguments: vec![Argument {
|
||||
name: "test_subcommand_arg".to_owned(),
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
Command {
|
||||
name: "test_subcommand2".to_owned(),
|
||||
arguments: vec![Argument {
|
||||
name: "test_subcommand_arg".to_owned(),
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
arguments: vec![Argument {
|
||||
name: "test_command_arg".to_owned(),
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
});
|
||||
|
||||
let (found_signature, index) = get_matching_signature_for_input(
|
||||
"test_command test_subcommand1 some_arg_value ",
|
||||
®istry,
|
||||
)
|
||||
.expect("Signature should be found.");
|
||||
assert_eq!(found_signature.name, "test_subcommand1");
|
||||
assert_eq!(index, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_matching_signature_for_input_without_trailing_whitespace() {
|
||||
let registry = CommandRegistry::new();
|
||||
registry.register_signature(CommandSignature {
|
||||
command: Command {
|
||||
name: "test_command".to_owned(),
|
||||
subcommands: vec![Command {
|
||||
name: "test_subcommand".to_owned(),
|
||||
..Default::default()
|
||||
}],
|
||||
arguments: vec![Argument {
|
||||
name: "arg1".to_owned(),
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
});
|
||||
|
||||
let (found_signature, index) =
|
||||
get_matching_signature_for_input("test_command test_subcommand", ®istry)
|
||||
.expect("Signature should be found.");
|
||||
|
||||
// The matched signature should be that of the top-level command. Because there is no trailing
|
||||
// whitespace in the input, it's assumed we're still completing on the "test_subcommand", so we
|
||||
// should still be using the top-level command signature.
|
||||
assert_eq!(found_signature.name, "test_command");
|
||||
assert_eq!(index, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_matching_signature_for_tokenized_input() {
|
||||
let registry = CommandRegistry::new();
|
||||
registry.register_signature(CommandSignature {
|
||||
command: Command {
|
||||
name: "test_command".to_owned(),
|
||||
subcommands: vec![
|
||||
Command {
|
||||
name: "test_subcommand1".to_owned(),
|
||||
arguments: vec![Argument {
|
||||
name: "test_subcommand_arg".to_owned(),
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
Command {
|
||||
name: "test_subcommand2".to_owned(),
|
||||
arguments: vec![Argument {
|
||||
name: "test_subcommand_arg".to_owned(),
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
arguments: vec![Argument {
|
||||
name: "test_command_arg".to_owned(),
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
});
|
||||
|
||||
let (found_signature, token_index) = get_matching_signature_for_tokenized_input(
|
||||
&["test_command", "test_subcommand1", "some_arg_value"],
|
||||
true,
|
||||
®istry,
|
||||
)
|
||||
.expect("Signature should be found.");
|
||||
assert_eq!(found_signature.name, "test_subcommand1");
|
||||
assert_eq!(token_index, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_matching_signature_for_tokenized_input_without_trailing_whitespace() {
|
||||
let registry = CommandRegistry::new();
|
||||
registry.register_signature(CommandSignature {
|
||||
command: Command {
|
||||
name: "test_command".to_owned(),
|
||||
subcommands: vec![
|
||||
Command {
|
||||
name: "test_subcommand1".to_owned(),
|
||||
arguments: vec![Argument {
|
||||
name: "test_subcommand_arg".to_owned(),
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
Command {
|
||||
name: "test_subcommand2".to_owned(),
|
||||
arguments: vec![Argument {
|
||||
name: "test_subcommand_arg".to_owned(),
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
arguments: vec![Argument {
|
||||
name: "test_command_arg".to_owned(),
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
});
|
||||
|
||||
let (found_signature, token_index) = get_matching_signature_for_tokenized_input(
|
||||
&["test_command", "test_subcommand1"],
|
||||
false,
|
||||
®istry,
|
||||
)
|
||||
.expect("Signature should be found.");
|
||||
|
||||
// The matched signature should be that of the top-level command. Because there is no trailing
|
||||
// whitespace in the input, it's assumed we're still completing on the "test_subcommand", so we
|
||||
// should still be using the top-level command signature.
|
||||
assert_eq!(found_signature.name, "test_command");
|
||||
assert_eq!(token_index, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_matching_signature_skips_flag_with_value_before_subcommand() {
|
||||
let registry = CommandRegistry::new();
|
||||
registry.register_signature(test_command_signature_with_options(vec![valued_option()]));
|
||||
|
||||
// -n takes a value, so the parser should skip "-n val" and find test_subcommand.
|
||||
let (found_signature, token_index) = get_matching_signature_for_tokenized_input(
|
||||
&["test_command", "-n", "val", "test_subcommand"],
|
||||
true,
|
||||
®istry,
|
||||
)
|
||||
.expect("Signature should be found.");
|
||||
assert_eq!(found_signature.name, "test_subcommand");
|
||||
assert_eq!(token_index, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_matching_signature_skips_long_flag_with_value_before_subcommand() {
|
||||
let registry = CommandRegistry::new();
|
||||
registry.register_signature(test_command_signature_with_options(vec![
|
||||
Opt {
|
||||
name: vec!["--context".to_owned()],
|
||||
arguments: vec![Argument {
|
||||
name: "context".to_owned(),
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
valued_option(),
|
||||
]));
|
||||
|
||||
// Two valued flags before the subcommand should both be skipped.
|
||||
let (found_signature, token_index) = get_matching_signature_for_tokenized_input(
|
||||
&[
|
||||
"test_command",
|
||||
"--context",
|
||||
"staging",
|
||||
"-n",
|
||||
"project1",
|
||||
"test_subcommand",
|
||||
],
|
||||
true,
|
||||
®istry,
|
||||
)
|
||||
.expect("Signature should be found.");
|
||||
assert_eq!(found_signature.name, "test_subcommand");
|
||||
assert_eq!(token_index, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_matching_signature_skips_switch_flag_before_subcommand() {
|
||||
let registry = CommandRegistry::new();
|
||||
registry.register_signature(test_command_signature_with_options(vec![Opt {
|
||||
name: vec!["--verbose".to_owned()],
|
||||
arguments: vec![],
|
||||
..Default::default()
|
||||
}]));
|
||||
|
||||
let (found_signature, token_index) = get_matching_signature_for_tokenized_input(
|
||||
&["test_command", "--verbose", "test_subcommand"],
|
||||
true,
|
||||
®istry,
|
||||
)
|
||||
.expect("Signature should be found.");
|
||||
assert_eq!(found_signature.name, "test_subcommand");
|
||||
assert_eq!(token_index, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_matching_signature_flag_at_end_without_value_does_not_panic() {
|
||||
let registry = CommandRegistry::new();
|
||||
registry.register_signature(test_command_signature_with_options(vec![valued_option()]));
|
||||
|
||||
// "-n" with no value should not panic.
|
||||
let (found_signature, token_index) =
|
||||
get_matching_signature_for_tokenized_input(&["test_command", "-n"], true, ®istry)
|
||||
.expect("Signature should be found.");
|
||||
assert_eq!(found_signature.name, "test_command");
|
||||
// No subcommand found, so entry_token_index (0) is returned.
|
||||
assert_eq!(token_index, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_matching_signature_skips_unrecognized_flag_before_subcommand() {
|
||||
// Unrecognized flags (tokens starting with '-' not in the spec) should be
|
||||
// skipped so the parser can still discover subcommands after them.
|
||||
let registry = CommandRegistry::new();
|
||||
registry.register_signature(test_command_signature_with_options(vec![]));
|
||||
|
||||
let (found_signature, token_index) = get_matching_signature_for_tokenized_input(
|
||||
&["test_command", "--unknown-flag", "test_subcommand"],
|
||||
true,
|
||||
®istry,
|
||||
)
|
||||
.expect("Signature should be found.");
|
||||
assert_eq!(found_signature.name, "test_subcommand");
|
||||
assert_eq!(token_index, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_matching_signature_flag_arg_consumes_token_matching_subcommand_name() {
|
||||
// When a recognized flag takes a required argument, the next token is
|
||||
// consumed as the flag's value even if it matches a subcommand name.
|
||||
let registry = CommandRegistry::new();
|
||||
registry.register_signature(test_command_signature_with_options(vec![valued_option()]));
|
||||
|
||||
let (found_signature, token_index) = get_matching_signature_for_tokenized_input(
|
||||
&["test_command", "-n", "test_subcommand", "extra"],
|
||||
true,
|
||||
®istry,
|
||||
)
|
||||
.expect("Signature should be found.");
|
||||
// "test_subcommand" was consumed as -n's value, so no subcommand is found.
|
||||
assert_eq!(found_signature.name, "test_command");
|
||||
assert_eq!(token_index, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_matching_signature_only_flags_no_subcommand() {
|
||||
// When the input consists only of flags with no following subcommand,
|
||||
// the parent command should be returned at the entry index.
|
||||
let registry = CommandRegistry::new();
|
||||
registry.register_signature(test_command_signature_with_options(vec![valued_option()]));
|
||||
|
||||
let (found_signature, token_index) =
|
||||
get_matching_signature_for_tokenized_input(&["test_command", "-n", "val"], true, ®istry)
|
||||
.expect("Signature should be found.");
|
||||
assert_eq!(found_signature.name, "test_command");
|
||||
assert_eq!(token_index, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_matching_signature_optional_flag_arg_does_not_consume_subcommand() {
|
||||
// A flag with 1 required + 1 optional argument should only skip the
|
||||
// required arg, so the next token can still match a subcommand.
|
||||
let registry = CommandRegistry::new();
|
||||
registry.register_signature(test_command_signature_with_options(vec![Opt {
|
||||
name: vec!["--output".to_owned()],
|
||||
arguments: vec![
|
||||
Argument {
|
||||
name: "format".to_owned(),
|
||||
..Default::default()
|
||||
},
|
||||
Argument {
|
||||
name: "extra".to_owned(),
|
||||
optional: true,
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
..Default::default()
|
||||
}]));
|
||||
|
||||
// "json" is the required arg, "test_subcommand" should not be consumed as
|
||||
// the optional arg.
|
||||
let (found_signature, token_index) = get_matching_signature_for_tokenized_input(
|
||||
&["test_command", "--output", "json", "test_subcommand"],
|
||||
true,
|
||||
®istry,
|
||||
)
|
||||
.expect("Signature should be found.");
|
||||
assert_eq!(found_signature.name, "test_subcommand");
|
||||
assert_eq!(token_index, 3);
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
//! This module contains Command Signature types for use with the v2 (JS-compatible) completions
|
||||
//! engine.
|
||||
|
||||
// The `js` module contains implementations of `galaxy_js::{IntoWarpJs, FromWarpJs}` for V2 command
|
||||
// signatures, which is only supported on native non-wasm platforms.
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
mod js;
|
||||
|
||||
mod lookup;
|
||||
mod registry;
|
||||
|
||||
use std::cmp::Ordering;
|
||||
|
||||
pub use lookup::*;
|
||||
pub use registry::*;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use galaxy_js::TypedJsFunctionRef;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "test-util", derive(Default))]
|
||||
pub struct CommandSignature {
|
||||
pub command: Command,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "test-util", derive(Default))]
|
||||
pub struct Command {
|
||||
pub name: String,
|
||||
pub alias: Vec<String>,
|
||||
pub description: Option<String>,
|
||||
pub arguments: Vec<Argument>,
|
||||
pub subcommands: Vec<Command>,
|
||||
pub options: Vec<Opt>,
|
||||
pub priority: Priority,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "test-util", derive(Default))]
|
||||
pub struct Argument {
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub values: Vec<ArgumentValue>,
|
||||
pub optional: bool,
|
||||
pub arity: Option<Arity>,
|
||||
}
|
||||
|
||||
impl Argument {
|
||||
pub fn is_variadic(&self) -> bool {
|
||||
self.arity
|
||||
.as_ref()
|
||||
.is_some_and(|arity| arity.limit.is_none())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "test-util", derive(Default))]
|
||||
pub struct Arity {
|
||||
pub limit: Option<usize>,
|
||||
pub delimiter: Option<ArgumentDelimiter>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ArgumentDelimiter(pub String);
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum ArgumentValue {
|
||||
Suggestion(Suggestion),
|
||||
Template {
|
||||
type_name: TemplateType,
|
||||
filter_name: Option<String>,
|
||||
},
|
||||
Generator(GeneratorFn),
|
||||
/// The argument itself is a root command.
|
||||
///
|
||||
/// This is the appropriate `ArgumentValue` for commands that take a full command as an
|
||||
/// argument: `time` or `sudo`, for example.
|
||||
RootCommand,
|
||||
}
|
||||
|
||||
/// The final set of results returned from a custom `GeneratorFn` or from a `GeneratorFn`'s
|
||||
/// `post_process` function.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GeneratorResults {
|
||||
/// The list of completion suggestions.
|
||||
pub suggestions: Vec<Suggestion>,
|
||||
|
||||
/// `true` if the order of `suggestions` should be preserved.
|
||||
///
|
||||
/// If `false`, `suggestions` may be re-ordered by the internal completions engine in the final
|
||||
/// result set.
|
||||
pub is_ordered: bool,
|
||||
}
|
||||
|
||||
/// The input struct passed to custom generator functions.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GeneratorCompletionContext {
|
||||
/// The tokens in the input for which completion suggestions are being generated.
|
||||
pub tokens: Vec<String>,
|
||||
|
||||
/// The current working directory of the session.
|
||||
pub pwd: String,
|
||||
}
|
||||
|
||||
/// The Rust representation of a JS Function used to generate argument value suggestions.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum GeneratorFn {
|
||||
/// A generator that computes suggestions by executing the given `script` (a sh command) and
|
||||
/// "post-processing" its stdout with the given `post_process` fn.
|
||||
ShellCommand {
|
||||
script: GeneratorScript,
|
||||
|
||||
/// If `None`, a "default" post process function implementation is used, where
|
||||
/// `Suggestion`s are created from each line in `script`'s stdout. The returned
|
||||
/// `GeneratorResults` object's `is_ordered` is set to `false`.
|
||||
post_process: Option<TypedJsFunctionRef<String, GeneratorResults>>,
|
||||
},
|
||||
/// An entirely user-specified JS function that generates suggestions based on the given
|
||||
/// `GeneratorCompletionContext`.
|
||||
Custom(TypedJsFunctionRef<GeneratorCompletionContext, GeneratorResults>),
|
||||
}
|
||||
|
||||
/// The command to be executed as part of a `GeneratorFn::ShellCommand` to generate argument
|
||||
/// suggestion values.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum GeneratorScript {
|
||||
Static(String),
|
||||
/// A JS function that dynamically computes the command to be run based on the tokenized input
|
||||
/// for which suggestions are being generated.
|
||||
Dynamic(TypedJsFunctionRef<Vec<String>, String>),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "test-util", derive(Default))]
|
||||
pub struct Suggestion {
|
||||
pub value: String,
|
||||
pub display_value: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub priority: Priority,
|
||||
}
|
||||
|
||||
/// The lowest priority value a completion object can have.
|
||||
const MIN_PRIORITY: i32 = -100;
|
||||
|
||||
/// The priority value of a completion object if not otherwise specifiied.
|
||||
const DEFAULT_PRIORITY: i32 = 0;
|
||||
|
||||
/// The highest priority value a completion object can have.
|
||||
const MAX_PRIORITY: i32 = 100;
|
||||
|
||||
/// Priority is a property of Commands, Subcommands and Options that influences where in the
|
||||
/// suggestion list those objects appear. It is represented as an integer between -100 and 100
|
||||
/// (inclusive) with 0 as the default.
|
||||
#[derive(PartialEq, Eq, Serialize, Deserialize, Debug, Copy, Clone)]
|
||||
pub struct Priority(i32);
|
||||
|
||||
impl Priority {
|
||||
/// Creates a Priority 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))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum TemplateType {
|
||||
Files,
|
||||
Folders,
|
||||
FilesAndFolders,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "test-util", derive(Default))]
|
||||
pub struct Opt {
|
||||
pub name: Vec<String>,
|
||||
pub description: Option<String>,
|
||||
pub arguments: Vec<Argument>,
|
||||
pub required: bool,
|
||||
pub priority: Priority,
|
||||
}
|
||||
|
||||
impl Opt {
|
||||
/// Returns `true` if this `Opt` has the given name.
|
||||
///
|
||||
/// Note that the given `name` should not include any leading hyphens; for example, this
|
||||
/// returns true for an `Opt` with names ['-f', '--foo'] given name 'f' or 'foo'.
|
||||
pub fn has_name(&self, name: impl AsRef<str>) -> bool {
|
||||
self.name.iter().any(|option_name| {
|
||||
if let Some(rest) = option_name.strip_prefix("--") {
|
||||
rest == name.as_ref()
|
||||
} else if let Some(rest) = option_name.strip_prefix('-') {
|
||||
rest == name.as_ref()
|
||||
} else {
|
||||
false
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "signatures_test.rs"]
|
||||
mod test;
|
||||
@@ -0,0 +1,55 @@
|
||||
//! `CommandRegistry` contains all registered `CommandSignature`s that are eligible for use in
|
||||
//! completion suggestion generation.
|
||||
//!
|
||||
//! Completion engine callers must supply a `CommandRegistry` in their `CompletionContext`
|
||||
//! implementation to generate suggestions for registered commands.
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
use memo_map::MemoMap;
|
||||
|
||||
use super::CommandSignature;
|
||||
|
||||
static GLOBAL_REGISTRY: OnceLock<Arc<CommandRegistry>> = OnceLock::new();
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct CommandRegistry {
|
||||
signatures: MemoMap<String, CommandSignature>,
|
||||
}
|
||||
|
||||
impl CommandRegistry {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
signatures: MemoMap::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_signature(&self, command: impl AsRef<str>) -> Option<&CommandSignature> {
|
||||
self.signatures.get(command.as_ref())
|
||||
}
|
||||
|
||||
pub fn register_signature(&self, signature: CommandSignature) {
|
||||
self.signatures
|
||||
.insert(signature.command.name.clone(), signature);
|
||||
}
|
||||
|
||||
pub fn registered_commands(&self) -> impl Iterator<Item = &str> {
|
||||
self.signatures.keys().map(|key| key.as_str())
|
||||
}
|
||||
|
||||
pub fn global_instance() -> Arc<Self> {
|
||||
GLOBAL_REGISTRY
|
||||
.get_or_init(|| {
|
||||
// TODO(wasm): Determine how to asynchronously load command signatures on wasm.
|
||||
Arc::new(CommandRegistry::new())
|
||||
})
|
||||
.clone()
|
||||
}
|
||||
|
||||
pub fn empty() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "registry_test.rs"]
|
||||
mod test;
|
||||
@@ -0,0 +1,28 @@
|
||||
use crate::signatures::{Command, Priority};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn test_signature() -> CommandSignature {
|
||||
CommandSignature {
|
||||
command: Command {
|
||||
name: "test".to_owned(),
|
||||
alias: vec![],
|
||||
description: None,
|
||||
arguments: vec![],
|
||||
subcommands: vec![],
|
||||
options: vec![],
|
||||
priority: Priority::default(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_command_registry_registers_signature() {
|
||||
let registry = CommandRegistry::new();
|
||||
registry.register_signature(test_signature());
|
||||
|
||||
let signature = registry
|
||||
.get_signature("test")
|
||||
.expect("Signature is registered.");
|
||||
assert_eq!(signature.command.name, "test");
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
use super::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(200);
|
||||
let important = Priority::new(40);
|
||||
let not_important = Priority::new(-80);
|
||||
|
||||
assert!(super_important == super_important);
|
||||
assert!(super_important > important);
|
||||
assert!(super_important > not_important);
|
||||
|
||||
assert!(important == important);
|
||||
assert!(important > not_important);
|
||||
|
||||
assert!(not_important == not_important);
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
use crate::{
|
||||
completer::{describe_given_token, CompletionContext},
|
||||
meta::{HasSpan as _, Span, SpannedItem},
|
||||
parsers::{simple::all_parsed_commands, LiteCommand},
|
||||
ParsedCommandsSnapshot, ParsedTokenData, ParsedTokensSnapshot,
|
||||
};
|
||||
|
||||
/// Parse the current commands in the editor's buffer and get descriptions
|
||||
/// for tokens within the commands. Note that this can be somewhat expensive,
|
||||
/// which is why we execute this function asynchronously.
|
||||
pub async fn parse_current_commands_and_tokens<T: CompletionContext>(
|
||||
buffer_text: String,
|
||||
completion_context: &T,
|
||||
) -> ParsedTokensSnapshot {
|
||||
// Parse commands
|
||||
let all_commands_iterator =
|
||||
all_parsed_commands(buffer_text.as_str(), completion_context.escape_char());
|
||||
// Note that we must collect the iterator into a vector to avoid referencing local data i.e. buffer_text within the future's output.
|
||||
let all_commands_vec: Vec<LiteCommand> = all_commands_iterator.collect();
|
||||
let parsed_commands_snapshot = ParsedCommandsSnapshot {
|
||||
buffer_text: buffer_text.clone(),
|
||||
parsed_commands: all_commands_vec,
|
||||
completion_context,
|
||||
};
|
||||
// Get descriptions for tokens within commands.
|
||||
let parsed_tokens = get_token_descriptions(parsed_commands_snapshot).await;
|
||||
|
||||
ParsedTokensSnapshot {
|
||||
buffer_text,
|
||||
parsed_tokens,
|
||||
}
|
||||
}
|
||||
|
||||
/// Expands aliases in the provided snapshot iteratively until no aliases are found.
|
||||
pub async fn expand_aliases<T: CompletionContext>(
|
||||
mut parsed_tokens_snapshot: ParsedTokensSnapshot,
|
||||
completion_context: &T,
|
||||
) -> ParsedTokensSnapshot {
|
||||
// Perform up to three iterations of alias expansion (to hedge against recursive aliases).
|
||||
for _ in 0..3 {
|
||||
let mut expanded_buffer_text = String::new();
|
||||
let mut last_token_end = 0;
|
||||
for token in &parsed_tokens_snapshot.parsed_tokens {
|
||||
if token.token_index != 0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(alias) = completion_context.alias_command(token.token.as_str()) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
// Push any text between the last token and the current token.
|
||||
expanded_buffer_text.push_str(
|
||||
&parsed_tokens_snapshot.buffer_text[last_token_end..token.token.span().start()],
|
||||
);
|
||||
// Push the alias.
|
||||
expanded_buffer_text.push_str(alias);
|
||||
// Store the position in the buffer text that we've processed up to so far.
|
||||
last_token_end = token.token.span().end();
|
||||
}
|
||||
|
||||
if expanded_buffer_text.is_empty() {
|
||||
// If we didn't find any aliases, we're done.
|
||||
break;
|
||||
} else {
|
||||
// Push any additional trailing content after the last expanded token.
|
||||
if last_token_end < parsed_tokens_snapshot.buffer_text.len() {
|
||||
expanded_buffer_text
|
||||
.push_str(&parsed_tokens_snapshot.buffer_text[last_token_end..]);
|
||||
}
|
||||
// Update the parsed tokens snapshot.
|
||||
parsed_tokens_snapshot =
|
||||
parse_current_commands_and_tokens(expanded_buffer_text, completion_context).await;
|
||||
}
|
||||
}
|
||||
|
||||
parsed_tokens_snapshot
|
||||
}
|
||||
|
||||
/// Get a vector of parsed tokens data from a given parsed commands snapshot,
|
||||
/// note that this is meant to run asynchronously.
|
||||
async fn get_token_descriptions<'a, T: CompletionContext>(
|
||||
parsed_commands_snapshot: ParsedCommandsSnapshot<'a, T>,
|
||||
) -> Vec<ParsedTokenData> {
|
||||
let buffer_text = parsed_commands_snapshot.buffer_text.as_str();
|
||||
let completion_context = parsed_commands_snapshot.completion_context;
|
||||
let mut parsed_token_data = Vec::new();
|
||||
|
||||
for parsed_command in parsed_commands_snapshot.parsed_commands {
|
||||
let current_command_span = parsed_command.span();
|
||||
|
||||
for (token_index, token) in parsed_command.parts.into_iter().enumerate() {
|
||||
// Split --flag=value tokens into separate flag-name and value entries so that each
|
||||
// part gets its own syntax highlighting color and description.
|
||||
let eq_split = token
|
||||
.item
|
||||
.starts_with('-')
|
||||
.then(|| token.item.find('='))
|
||||
.flatten();
|
||||
|
||||
if let Some(eq_pos) = eq_split {
|
||||
let eq_byte_pos = token.span.start() + eq_pos;
|
||||
|
||||
let flag_token = token.item[..eq_pos]
|
||||
.to_string()
|
||||
.spanned(Span::new(token.span.start(), eq_byte_pos));
|
||||
let flag_description = describe_given_token(
|
||||
buffer_text,
|
||||
¤t_command_span,
|
||||
flag_token.clone(),
|
||||
completion_context,
|
||||
)
|
||||
.await;
|
||||
parsed_token_data.push(ParsedTokenData {
|
||||
token: flag_token,
|
||||
token_index,
|
||||
token_description: flag_description,
|
||||
});
|
||||
|
||||
let value_token = token.item[eq_pos + 1..]
|
||||
.to_string()
|
||||
.spanned(Span::new(eq_byte_pos + 1, token.span.end()));
|
||||
if !value_token.item.is_empty() {
|
||||
let value_description = describe_given_token(
|
||||
buffer_text,
|
||||
¤t_command_span,
|
||||
value_token.clone(),
|
||||
completion_context,
|
||||
)
|
||||
.await;
|
||||
parsed_token_data.push(ParsedTokenData {
|
||||
token: value_token,
|
||||
token_index,
|
||||
token_description: value_description,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
let token_description = describe_given_token(
|
||||
buffer_text,
|
||||
¤t_command_span,
|
||||
token.clone(),
|
||||
completion_context,
|
||||
)
|
||||
.await;
|
||||
|
||||
// Note that this is the token index relative to the current command meaning that
|
||||
// in the final flattened vector, we could have multiple tokens with index 0.
|
||||
parsed_token_data.push(ParsedTokenData {
|
||||
token,
|
||||
token_index,
|
||||
token_description,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
parsed_token_data
|
||||
}
|
||||
Reference in New Issue
Block a user