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

This commit is contained in:
Ryan Ward
2026-05-07 11:29:34 -05:00
parent f4e2475c60
commit a41cbd8cc7
2433 changed files with 14208 additions and 9409 deletions
@@ -0,0 +1,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);
}