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

This commit is contained in:
Ryan Ward
2026-05-07 11:29:34 -05:00
parent f4e2475c60
commit a41cbd8cc7
2433 changed files with 14208 additions and 9409 deletions
+62
View File
@@ -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);
}
+64
View File
@@ -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>,
}
+221
View File
@@ -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;
+27
View File
@@ -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)
}
+334
View File
@@ -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 => ">",
}
}
}
+436
View File
@@ -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,
&registry,
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,
&registry,
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,
&registry,
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,
&registry,
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,
&registry,
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,
&registry,
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,
&registry,
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,
&registry,
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,
})
)
}
+435
View File
@@ -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(&registry);
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 ", &registry)
.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 ", &registry)
.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 ", &registry)
.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 ",
&registry,
)
.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", &registry)
.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,
&registry,
)
.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,
&registry,
)
.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,
&registry,
)
.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,
&registry,
)
.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,
&registry,
)
.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, &registry)
.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,
&registry,
)
.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,
&registry,
)
.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, &registry)
.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,
&registry,
)
.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);
}
+158
View File
@@ -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,
&current_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,
&current_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,
&current_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
}