use crate::spec::{ArgMeta, CommandMeta, CommandSelector, FlagMeta, Spec, SpecView};
pub use crate::spec::{Candidate, CandidateKind, CompleteCtx, Completer};
use crate::{Arg, Command, Error, Flag, Parser};
use core::future::Future;
use core::pin::Pin;
use std::ffi::OsString;
#[derive(Debug, Clone)]
pub struct Position<'t> {
pub cmd: &'t Command<'t>,
pub flags_possible: bool,
pub awaiting_value: Option<&'t Flag<'t>>,
pub next_arg: Option<&'t Arg<'t>>,
pub next_arg_values: u32,
pub separator_seen: bool,
pub help_topic: bool,
pub command_start: usize,
pub path: Vec<(&'t Command<'t>, usize)>,
pub flags: Vec<&'t Flag<'t>>,
pub external: Option<usize>,
}
pub fn walk<'t>(root: &'t Command<'t>, words: &[String]) -> Position<'t> {
walk_inner(root, words, None)
}
pub fn walk_view<'t>(
root: &'t Command<'t>,
words: &[String],
view: &'t crate::spec::ViewMeta<'t>,
) -> Position<'t> {
let mut projected = words.to_vec();
let route: Vec<String> = view
.root
.split_ascii_whitespace()
.map(str::to_string)
.collect();
let count = route.len();
projected.splice(0..0, route);
let mut position = walk_inner(root, &projected, Some(view));
let original_index = |index: usize| index.saturating_sub(count);
position.command_start = original_index(position.command_start);
position.external = position.external.map(original_index);
for (_, start) in &mut position.path {
*start = original_index(*start);
}
position
}
fn walk_inner<'t>(
root: &'t Command<'t>,
words: &[String],
view: Option<&'t crate::spec::ViewMeta<'t>>,
) -> Position<'t> {
let argv: Vec<&std::ffi::OsStr> = words.iter().map(std::ffi::OsStr::new).collect();
let mut parser = Parser::for_completion(root, &argv);
if let Some(view) = view {
parser = parser.with_view(view);
}
let mut awaiting_value = None;
let mut last_arg = None;
let mut last_arg_values = 0u32;
let mut external = None;
while let Some(event) = parser.next_event() {
match event {
Ok(crate::Event::Arg { arg, .. }) => {
if last_arg.is_some_and(|prior| core::ptr::eq(prior, arg)) {
last_arg_values = last_arg_values.saturating_add(1);
} else {
last_arg = Some(arg);
last_arg_values = 1;
}
}
Ok(crate::Event::External { values }) => {
external = Some(argv.len() - values.len());
}
Ok(_) => {}
Err(Error::MissingFlagValue { flag }) => {
awaiting_value = Some(flag);
break;
}
Err(Error::Help { cmd, .. }) if parser.help_span() != (0, 0) => {
return Position {
cmd,
flags_possible: false,
awaiting_value: None,
next_arg: None,
next_arg_values: 0,
path: Vec::new(),
separator_seen: false,
command_start: 0,
help_topic: true,
flags: Vec::new(),
external: None,
}
}
Err(_) => break,
}
}
let next_arg = parser.pending_arg();
Position {
path: parser.command_path(),
cmd: parser.command(),
flags_possible: !parser.flags_stopped(),
awaiting_value: awaiting_value.or_else(|| parser.collecting()),
next_arg,
next_arg_values: next_arg
.filter(|arg| last_arg.is_some_and(|prior| core::ptr::eq(prior, *arg)))
.map_or(0, |_| last_arg_values),
separator_seen: parser.double_dash_seen(),
command_start: parser.command_start(),
help_topic: false,
flags: parser.flags_in_scope().collect(),
external,
}
}
pub fn for_name<'a>(
spec: &Spec<'a>,
name: &str,
ctx: &CompleteCtx<'_>,
) -> Option<Vec<Candidate<'static>>> {
let reached = walk(spec.root.cmd, ctx.command_words_start());
for_name_at(spec, name, ctx, &reached, None)
}
pub fn for_name_view<'a>(
spec: &Spec<'a>,
name: &str,
ctx: &CompleteCtx<'_>,
view: &'a crate::spec::ViewMeta<'a>,
) -> Option<Vec<Candidate<'static>>> {
let reached = walk_view(spec.root.cmd, ctx.command_words_start(), view);
for_name_at(spec, name, ctx, &reached, Some(view))
}
fn for_name_at<'a>(
spec: &Spec<'a>,
name: &str,
ctx: &CompleteCtx<'_>,
reached: &Position<'a>,
view: Option<&'a crate::spec::ViewMeta<'a>>,
) -> Option<Vec<Candidate<'static>>> {
fn on(meta: &CommandMeta<'_>, name: &str) -> Option<Completer> {
for arg in meta.args {
if arg.arg.name.eq_ignore_ascii_case(name) {
if let Some(completer) = arg.complete {
return Some(completer);
}
}
}
for flag in meta.flags {
let value = flag.value_name.unwrap_or(flag.flag.name);
if value.eq_ignore_ascii_case(name) {
if let Some(completer) = flag.complete {
return Some(completer);
}
}
}
None
}
fn find<'m, 's>(meta: &'m CommandMeta<'s>, name: &str) -> Option<&'m CommandMeta<'s>> {
if on(meta, name).is_some() {
return Some(meta);
}
meta.subcommands.iter().find_map(|sub| find(sub, name))
}
fn at_cursor(meta: &CommandMeta<'_>, name: &str, position: &Position<'_>) -> Option<Completer> {
if let Some(wanted) = position.awaiting_value {
let found = meta.flags.iter().find(|field| {
let value = field.value_name.unwrap_or(field.flag.name);
core::ptr::eq(field.flag, wanted) && value.eq_ignore_ascii_case(name)
});
if let Some(completer) = found.and_then(|field| field.complete) {
return Some(completer);
}
}
if let Some(wanted) = position.next_arg {
let found = meta.args.iter().find(|field| {
core::ptr::eq(field.arg, wanted) && field.arg.name.eq_ignore_ascii_case(name)
});
if let Some(completer) = found.and_then(|field| field.complete) {
return Some(completer);
}
}
None
}
let chain = metadata_chain_on_route(spec, reached).unwrap_or_default();
let at_cursor = chain
.iter()
.rev()
.find_map(|meta| at_cursor(meta, name, reached));
let fallback = if let Some(view) = view {
let depth = view.root.split_ascii_whitespace().count();
chain.get(depth).copied().and_then(|promoted| {
let (flags, groups) = crate::help::view_root_fields(spec, promoted, view);
let projected = CommandMeta {
flags: &flags,
groups: &groups,
..*promoted
};
on(&projected, name)
.or_else(|| chain.last().copied().and_then(|meta| on(meta, name)))
.or_else(|| find(promoted, name).and_then(|meta| on(meta, name)))
})
} else {
on(spec.root, name)
.or_else(|| chain.last().copied().and_then(|meta| on(meta, name)))
.or_else(|| find(spec.root, name).and_then(|meta| on(meta, name)))
};
let completer = at_cursor.or(fallback)?;
let mut found = completer(ctx);
found.retain(|c| c.value.starts_with(ctx.prefix));
Some(found)
}
#[cfg(feature = "spec")]
pub fn completers_on(meta: &CommandMeta<'_>) -> Vec<String> {
let mut out = Vec::new();
for arg in meta.args {
if arg.complete.is_some() {
out.push(arg.arg.name.to_ascii_lowercase());
}
}
for flag in meta.flags {
if flag.complete.is_some() {
out.push(
flag.value_name
.unwrap_or(flag.flag.name)
.to_ascii_lowercase(),
);
}
}
out.sort_unstable();
out.dedup();
out
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Files {
Any,
Dirs,
ExecutablePaths,
Commands,
Extensions(Vec<String>),
}
impl core::fmt::Display for Files {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Self::Any => f.write_str("paths"),
Self::Dirs => f.write_str("directories"),
Self::ExecutablePaths => f.write_str("executable paths"),
Self::Commands => f.write_str("commands"),
Self::Extensions(extensions) => {
f.write_str("paths with extensions ")?;
for (index, extension) in extensions.iter().enumerate() {
if index > 0 {
f.write_str(", ")?;
}
write!(f, ".{extension}")?;
}
Ok(())
}
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Completions<'a> {
pub candidates: Vec<Candidate<'a>>,
pub files: Option<Files>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CompletionTrace<'a> {
pub words: Vec<String>,
pub cword: usize,
pub prefix: String,
pub command_path: Vec<&'a str>,
pub flags_possible: bool,
pub awaiting_value: Option<&'a str>,
pub next_arg: Option<&'a str>,
pub separator_seen: bool,
pub help_topic: bool,
pub candidates: Vec<Candidate<'a>>,
pub files: Option<Files>,
}
impl core::fmt::Display for CompletionTrace<'_> {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
writeln!(f, "words: {:?}", self.words)?;
writeln!(
f,
"cursor: word {} with prefix {:?}",
self.cword, self.prefix
)?;
writeln!(f, "command: {}", self.command_path.join(" "))?;
let position = if let Some(flag) = self.awaiting_value {
format!("value of {flag}")
} else if self.flags_possible && self.prefix.starts_with('-') {
"flag".to_string()
} else if let Some(arg) = self.next_arg {
format!("argument {arg}")
} else if self.help_topic {
"help topic".to_string()
} else {
"command or flag".to_string()
};
writeln!(f, "position: {position}")?;
writeln!(f, "flags possible: {}", self.flags_possible)?;
writeln!(f, "separator seen: {}", self.separator_seen)?;
let candidates = self
.candidates
.iter()
.map(|candidate| candidate.value.as_str())
.collect::<Vec<_>>()
.join(", ");
writeln!(f, "candidates: {candidates}")?;
match &self.files {
Some(files) => writeln!(f, "shell fallback: {files}"),
None => writeln!(f, "shell fallback: none"),
}
}
}
pub fn trace<'a>(spec: &Spec<'a>, split: &Split) -> CompletionTrace<'a> {
let position = walk(spec.root.cmd, split.argv());
let answer = complete(spec, split);
trace_from(spec, split, position, answer, None)
}
pub fn trace_view<'a>(
spec: &Spec<'a>,
split: &Split,
view: &'a crate::spec::ViewMeta<'a>,
) -> CompletionTrace<'a> {
let position = walk_view(spec.root.cmd, split.argv(), view);
let answer = complete_view(spec, split, view);
trace_from(spec, split, position, answer, Some(view))
}
fn trace_from<'a>(
spec: &Spec<'a>,
split: &Split,
position: Position<'a>,
answer: Completions<'a>,
view: Option<&crate::spec::ViewMeta<'_>>,
) -> CompletionTrace<'a> {
let chain = metadata_chain_on_route(spec, &position);
let meta = chain.as_ref().and_then(|chain| chain.last().copied());
let next_arg = if restarted(meta, split) {
meta.and_then(|meta| meta.args.first())
.map(|field| field.arg)
} else {
position
.next_arg
.or_else(|| default_subcommand_arg(spec, split, &position).map(|(_, field)| field.arg))
};
let command_path = if position.help_topic {
let argv = split
.argv()
.iter()
.map(std::ffi::OsStr::new)
.collect::<Vec<_>>();
let route = match view {
Some(view) => {
let mut view_argv = Vec::with_capacity(argv.len() + 1);
view_argv.push(std::ffi::OsStr::new(view.bin));
view_argv.extend_from_slice(&argv);
crate::help::route_to_view(spec.root.cmd, &view_argv, position.cmd, view)
}
None => crate::help::route_to(spec.root.cmd, &argv, position.cmd),
};
route
.map(|route| route.into_iter().map(|command| command.name).collect())
.or_else(|| chain.map(|chain| chain.into_iter().map(|meta| meta.cmd.name).collect()))
.unwrap_or_else(|| vec![position.cmd.name])
} else {
position
.path
.iter()
.map(|(command, _)| command.name)
.collect()
};
CompletionTrace {
words: split.words.clone(),
cword: split.cword,
prefix: split.prefix.clone(),
command_path,
flags_possible: position.flags_possible,
awaiting_value: position.awaiting_value.map(|flag| flag.name),
next_arg: next_arg.map(|arg| arg.name),
separator_seen: position.separator_seen,
help_topic: position.help_topic,
candidates: answer.candidates,
files: answer.files,
}
}
pub type CompletionFuture<'a> = Pin<Box<dyn Future<Output = Vec<Candidate<'static>>> + 'a>>;
pub type AsyncCompleter = for<'a> fn(CompleteCtx<'a>) -> CompletionFuture<'a>;
#[derive(Clone, Copy)]
pub enum CompletionHandler {
Sync(Completer),
Async(AsyncCompleter),
}
impl core::fmt::Debug for CompletionHandler {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.write_str(match self {
Self::Sync(_) => "Sync(..)",
Self::Async(_) => "Async(..)",
})
}
}
#[derive(Debug, Clone, Copy)]
pub struct CompletionOverlay<'a> {
pub command: CommandSelector<'a>,
pub value: &'a str,
pub handler: CompletionHandler,
}
impl<'a> CompletionOverlay<'a> {
pub const fn sync_any(value: &'a str, completer: Completer) -> Self {
Self {
command: CommandSelector::Any,
value,
handler: CompletionHandler::Sync(completer),
}
}
pub const fn async_any(value: &'a str, completer: AsyncCompleter) -> Self {
Self {
command: CommandSelector::Any,
value,
handler: CompletionHandler::Async(completer),
}
}
pub const fn sync(path: &'a str, value: &'a str, completer: Completer) -> Self {
Self {
command: CommandSelector::Path(path),
value,
handler: CompletionHandler::Sync(completer),
}
}
pub const fn asynchronous(path: &'a str, value: &'a str, completer: AsyncCompleter) -> Self {
Self {
command: CommandSelector::Path(path),
value,
handler: CompletionHandler::Async(completer),
}
}
}
#[derive(Debug, Clone)]
pub struct App<'a> {
view: SpecView<'a>,
overlays: &'a [CompletionOverlay<'a>],
projection: Option<&'a str>,
}
impl<'a> App<'a> {
pub const fn new(view: SpecView<'a>) -> Self {
Self {
view,
overlays: &[],
projection: None,
}
}
pub const fn completions(mut self, overlays: &'a [CompletionOverlay<'a>]) -> Self {
self.overlays = overlays;
self
}
pub const fn project(mut self, command_path: &'a str) -> Self {
self.projection = Some(command_path);
self
}
pub fn completion_script(self, shell: Shell) -> String {
let spec = self.view.spec();
crate::script::script(spec.bin.unwrap_or(spec.name), shell)
}
pub fn completion_script_for_alias(self, alias: &str, shell: Shell) -> String {
let spec = self.view.spec();
crate::script::script_for(spec.bin.unwrap_or(spec.name), alias, shell)
}
pub fn completion_install_plan(
self,
shell: Shell,
env: &crate::install::Env,
) -> Result<crate::install::Plan, crate::install::Error> {
let spec = self.view.spec();
crate::install::plan(spec.bin.unwrap_or(spec.name), shell, env)
}
pub fn completion_install_plan_for_alias(
self,
alias: &str,
shell: Shell,
env: &crate::install::Env,
) -> Result<crate::install::Plan, crate::install::Error> {
let spec = self.view.spec();
crate::install::plan_for(spec.bin.unwrap_or(spec.name), alias, shell, env)
}
pub fn install_completion(
self,
shell: Shell,
env: &crate::install::Env,
on_foreign: crate::install::OnForeign,
) -> Result<crate::install::Installed, crate::install::Error> {
let spec = self.view.spec();
crate::install::install(spec.bin.unwrap_or(spec.name), shell, env, on_foreign)
}
pub fn install_completion_for_alias(
self,
alias: &str,
shell: Shell,
env: &crate::install::Env,
on_foreign: crate::install::OnForeign,
) -> Result<crate::install::Installed, crate::install::Error> {
let spec = self.view.spec();
crate::install::install_for(spec.bin.unwrap_or(spec.name), alias, shell, env, on_foreign)
}
pub async fn completion_request(self, argv: &[OsString]) -> Option<String> {
let request = CompletionRequest::parse(argv)?;
let answer = self.complete_request(&request).await;
Some(render(&answer, request.shell))
}
pub fn effective_split(&self, split: &Split) -> Split {
let mut split = split.clone();
if let Some(path) = self.projection {
let projected: Vec<String> =
path.split_ascii_whitespace().map(str::to_string).collect();
let count = projected.len();
split.words.splice(1..1, projected);
if split.cword > 0 {
split.cword += count;
}
}
split
}
pub async fn complete_request(&self, request: &CompletionRequest) -> Completions<'a> {
let split = self.effective_split(&request.split);
let spec = self.view.spec();
match &request.candidates_for {
Some(name) => complete_named_with(&spec, &split, self.overlays, name).await,
None => complete_with(&spec, &split, self.overlays).await,
}
}
}
impl<'a> SpecView<'a> {
pub const fn completion_app(self) -> App<'a> {
App::new(self)
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct CompletionRequest {
pub shell: Shell,
pub split: Split,
pub candidates_for: Option<String>,
}
impl CompletionRequest {
pub const fn for_split(split: Split) -> Self {
Self {
shell: Shell::Bash,
split,
candidates_for: None,
}
}
pub fn parse(argv: &[OsString]) -> Option<Self> {
if argv.first()?.to_str()? != "__complete_word__" {
return None;
}
let mut shell = Shell::Bash;
let mut line = String::new();
let mut cursor = None;
let mut candidates_for = None;
let mut words: Option<Vec<String>> = None;
let mut rest = argv[1..].iter();
while let Some(arg) = rest.next() {
match arg.to_str().unwrap_or_default() {
"--shell" => {
if let Some(found) = rest
.next()
.and_then(|name| Shell::from_name(&name.to_string_lossy()))
{
shell = found;
}
}
"--line" => {
if let Some(value) = rest.next() {
line = value.to_string_lossy().into_owned();
}
}
"--cursor" => {
cursor = rest
.next()
.and_then(|value| value.to_str().and_then(|v| v.parse().ok()));
}
"--candidates" => {
candidates_for = rest.next().map(|v| v.to_string_lossy().into_owned());
}
"--words" => {
words = Some(
rest.map(|word| word.to_string_lossy().into_owned())
.collect(),
);
break;
}
_ => {}
}
}
let split = match words {
Some(mut words) => {
if words.is_empty() {
words.push(String::new());
}
let cword = words.len() - 1;
let prefix = words[cword].clone();
Split {
words,
cword,
prefix,
}
}
None => {
let cursor = cursor.unwrap_or(line.len());
split(&line, cursor, shell)
}
};
Some(Self {
shell,
split,
candidates_for,
})
}
}
pub const FILES_MARKER: &str = "\u{1}files";
pub const DIRS_MARKER: &str = "\u{1}dirs";
pub const EXECUTABLE_PATHS_MARKER: &str = "\u{1}executables";
pub const COMMANDS_MARKER: &str = "\u{1}commands";
pub fn render(answer: &Completions<'_>, shell: Shell) -> String {
let mut out = String::new();
let described = answer.candidates.iter().any(|c| c.description.is_some());
for candidate in &answer.candidates {
let description = one_line(candidate.description.as_deref().unwrap_or_default());
let description = description.as_str();
match shell {
Shell::Bash => out.push_str(&candidate.value),
Shell::Zsh => {
out.push_str(&one_line(
candidate.display.as_deref().unwrap_or(&candidate.value),
));
out.push('\t');
out.push_str(description);
out.push('\t');
out.push_str(&zsh_quote(&candidate.value));
}
Shell::PowerShell => {
out.push_str(&candidate.value);
out.push('\t');
out.push_str(description);
out.push('\t');
out.push_str(&one_line(
candidate.display.as_deref().unwrap_or(&candidate.value),
));
out.push('\t');
out.push_str(match candidate.kind {
CandidateKind::Value => "value",
CandidateKind::Command => "command",
CandidateKind::Flag => "flag",
CandidateKind::File => "file",
CandidateKind::Directory => "directory",
});
}
Shell::Elvish => {
out.push_str(&candidate.value);
out.push('\t');
out.push_str(description);
out.push('\t');
out.push_str(&one_line(
candidate.display.as_deref().unwrap_or(&candidate.value),
));
}
Shell::Fish | Shell::Nu => {
out.push_str(&candidate.value);
if described {
out.push('\t');
out.push_str(description);
}
}
}
out.push('\n');
}
match &answer.files {
Some(Files::Any) => {
out.push_str(FILES_MARKER);
out.push('\n');
}
Some(Files::Dirs) => {
out.push_str(DIRS_MARKER);
out.push('\n');
}
Some(Files::ExecutablePaths) => {
out.push_str(EXECUTABLE_PATHS_MARKER);
out.push('\n');
}
Some(Files::Commands) => {
out.push_str(COMMANDS_MARKER);
out.push('\n');
}
Some(Files::Extensions(extensions)) => {
out.push_str("\u{1}extensions");
for extension in extensions {
out.push('\t');
out.push_str(extension);
}
out.push('\n');
}
None => {}
}
out
}
fn one_line(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut spaced = false;
for c in text.chars() {
if matches!(c, '\n' | '\r' | '\t') {
if !spaced && !out.is_empty() {
out.push(' ');
spaced = true;
}
} else {
out.push(c);
spaced = false;
}
}
while out.ends_with(' ') {
out.pop();
}
out
}
fn zsh_quote(value: &str) -> String {
let safe = |c: char| {
c.is_ascii_alphanumeric()
|| matches!(c, '_' | '-' | '.' | '/' | ':' | '@' | '+' | '=' | '%' | ',')
};
if !value.is_empty() && value.chars().all(safe) {
return value.to_string();
}
format!("'{}'", value.replace('\'', "'\\''"))
}
fn files_for(name: &str) -> Option<Files> {
let matches = |want: &str| name.eq_ignore_ascii_case(want);
if matches("file") || matches("path") || matches("config_file") {
Some(Files::Any)
} else if matches("dir") || matches("directory") {
Some(Files::Dirs)
} else if matches("executable") {
Some(Files::ExecutablePaths)
} else if matches("command") {
Some(Files::Commands)
} else {
None
}
}
fn declared_files(type_: &str, next_arg_values: u32) -> Option<Files> {
if let Some(extensions) = type_
.strip_prefix("path:")
.or_else(|| type_.strip_prefix("file:"))
{
let extensions = extensions
.split(',')
.map(|extension| extension.trim().trim_start_matches('.'))
.filter(|extension| !extension.is_empty())
.map(str::to_string)
.collect::<Vec<_>>();
if !extensions.is_empty() {
return Some(Files::Extensions(extensions));
}
}
if type_.eq_ignore_ascii_case("command_args") {
return Some(if next_arg_values == 0 {
Files::Commands
} else {
Files::Any
});
}
files_for(type_)
}
fn declared_files_at_cursor(
spec: &Spec<'_>,
split: &Split,
position: &Position<'_>,
) -> Option<Files> {
let attached = attached_long_value(position, &split.prefix);
if split.cword == 0
|| (position.awaiting_value.is_none()
&& attached.is_none()
&& position.flags_possible
&& split.prefix.starts_with('-'))
{
return None;
}
let meta = metadata_chain_on_route(spec, position).and_then(|chain| chain.last().copied());
let after_restart = restarted(meta, split);
let at_cursor = if after_restart {
meta.and_then(|m| m.args.first()).map(|m| m.arg)
} else {
position
.next_arg
.or_else(|| default_subcommand_arg(spec, split, position).map(|(_, field)| field.arg))
};
if position.awaiting_value.is_none()
&& attached.is_none()
&& at_cursor.is_some_and(|arg| arg.double_dash == crate::DoubleDash::Required)
&& !position.separator_seen
{
return None;
}
let value_flag = position
.awaiting_value
.or_else(|| attached.map(|(flag, _, _)| flag));
let (name, complete_type) = if let Some(flag) = value_flag {
let meta = flag_meta(spec.root, flag);
(
meta.and_then(|m| m.value_name).or(Some(flag.name)),
meta.and_then(|m| m.complete_type),
)
} else if let Some(arg) = at_cursor {
let meta = arg_meta(spec.root, arg);
(Some(arg.name), meta.and_then(|m| m.complete_type))
} else {
(None, None)
};
match complete_type {
Some(type_) => declared_files(
type_,
if after_restart {
0
} else {
position.next_arg_values
},
)
.or_else(|| {
type_
.eq_ignore_ascii_case("unknown")
.then(|| name.and_then(files_for))
.flatten()
}),
None => name.and_then(files_for),
}
}
pub fn complete<'a>(spec: &Spec<'a>, split: &Split) -> Completions<'a> {
complete_inner(spec, split, None)
}
pub fn complete_view<'a>(
spec: &Spec<'a>,
split: &Split,
view: &'a crate::spec::ViewMeta<'a>,
) -> Completions<'a> {
complete_inner(spec, split, Some(view))
}
fn complete_inner<'a>(
spec: &Spec<'a>,
split: &Split,
view: Option<&'a crate::spec::ViewMeta<'a>>,
) -> Completions<'a> {
let position = match view {
Some(view) => walk_view(spec.root.cmd, split.argv(), view),
None => walk(spec.root.cmd, split.argv()),
};
if position.external.is_some() {
return Completions {
candidates: Vec::new(),
files: None,
};
}
let meta = metadata_chain_on_route(spec, &position).and_then(|chain| chain.last().copied());
let token = split.prefix.as_str();
let attached = attached_long_value(&position, token);
let candidates = candidates_inner(spec, split, view);
let after_restart = restarted(meta, split);
let at_cursor = if after_restart {
meta.and_then(|m| m.args.first()).map(|m| m.arg)
} else {
position.next_arg
};
let flag_like = attached.is_none() && position.flags_possible && token.starts_with('-');
let value_flag = position
.awaiting_value
.or_else(|| attached.map(|(flag, _, _)| flag));
let (named, declares_choices, complete_type) = if let Some(flag) = value_flag {
let meta = flag_meta(spec.root, flag);
(
meta.and_then(|m| m.value_name).or(Some(flag.name)),
meta.is_some_and(|m| !m.choices.is_empty() || !m.accepted_choices.is_empty()),
meta.and_then(|m| m.complete_type),
)
} else if let Some(arg) = at_cursor {
let meta = arg_meta(spec.root, arg);
(
Some(arg.name),
meta.is_some_and(|m| !m.choices.is_empty() || !m.accepted_choices.is_empty()),
meta.and_then(|m| m.complete_type),
)
} else {
(None, false, None)
};
let asked_for = match complete_type {
Some(type_) => declared_files(
type_,
if after_restart {
0
} else {
position.next_arg_values
},
)
.or_else(|| {
type_
.eq_ignore_ascii_case("unknown")
.then(|| named.and_then(files_for))
.flatten()
}),
None => named.and_then(files_for),
};
let needs_separator = value_flag.is_none()
&& at_cursor.is_some_and(|arg| arg.double_dash == crate::DoubleDash::Required)
&& !position.separator_seen;
let declared_non_file_type = complete_type
.is_some_and(|type_| !type_.eq_ignore_ascii_case("unknown") && asked_for.is_none());
let closed =
!candidates.is_empty() || declares_choices || declared_non_file_type || position.help_topic;
let files = if flag_like || needs_separator {
None
} else if asked_for.is_some() {
asked_for
} else if closed {
None
} else {
Some(Files::Any)
};
Completions { candidates, files }
}
pub async fn complete_with<'a>(
spec: &Spec<'a>,
split: &Split,
overlays: &[CompletionOverlay<'_>],
) -> Completions<'a> {
let position = walk(spec.root.cmd, split.argv());
let Some(overlay) = overlay_at_cursor(spec, split, &position, overlays) else {
return complete(spec, split);
};
let attached = attached_long_value(&position, &split.prefix);
let value_prefix = attached.map_or(split.prefix.as_str(), |(_, _, prefix)| prefix);
let words = split.argv();
let command_path: Vec<(&Command<'_>, &[String])> = position
.path
.iter()
.map(|(cmd, start)| (*cmd, words.get(*start..).unwrap_or(&[])))
.collect();
let ctx = CompleteCtx {
words: &split.words,
cword: split.cword,
prefix: value_prefix,
command_words: command_words(split, &position),
command_path: &command_path,
};
let mut dynamic = match overlay.handler {
CompletionHandler::Sync(completer) => completer(&ctx),
CompletionHandler::Async(completer) => completer(ctx).await,
};
dynamic.retain(|candidate| candidate.value.starts_with(value_prefix));
if let Some((_, form, _)) = attached {
attach_candidates(&mut dynamic, form);
}
let mut answer = complete(spec, split);
answer.candidates.extend(dynamic);
sort_and_dedup_candidates(&mut answer.candidates);
answer.files = declared_files_at_cursor(spec, split, &position);
answer
}
async fn complete_named_with<'a>(
spec: &Spec<'a>,
split: &Split,
overlays: &[CompletionOverlay<'_>],
name: &str,
) -> Completions<'a> {
let position = walk(spec.root.cmd, split.argv());
let attached = attached_long_value(&position, &split.prefix);
let value_prefix = attached.map_or(split.prefix.as_str(), |(_, _, prefix)| prefix);
let words = split.argv();
let command_path: Vec<(&Command<'_>, &[String])> = position
.path
.iter()
.map(|(cmd, start)| (*cmd, words.get(*start..).unwrap_or(&[])))
.collect();
let ctx = CompleteCtx {
words: &split.words,
cword: split.cword,
prefix: value_prefix,
command_words: command_words(split, &position),
command_path: &command_path,
};
let mut candidates = for_name(spec, name, &ctx).unwrap_or_default();
if let Some(overlay) = overlay_for_name(spec, split, &position, overlays, name) {
let mut dynamic = match overlay.handler {
CompletionHandler::Sync(completer) => completer(&ctx),
CompletionHandler::Async(completer) => completer(ctx).await,
};
candidates.append(&mut dynamic);
}
candidates.retain(|candidate| candidate.value.starts_with(value_prefix));
if let Some((_, form, _)) = attached {
attach_candidates(&mut candidates, form);
}
sort_and_dedup_candidates(&mut candidates);
Completions {
candidates,
files: None,
}
}
fn sort_and_dedup_candidates(candidates: &mut Vec<Candidate<'_>>) {
candidates.sort_unstable();
let mut deduped: Vec<Candidate<'_>> = Vec::with_capacity(candidates.len());
for mut candidate in candidates.drain(..) {
if let Some(existing) = deduped
.last_mut()
.filter(|existing| existing.value == candidate.value)
{
if existing.display.is_none() {
existing.display = candidate.display.take();
}
if existing.description.is_none() {
existing.description = candidate.description.take();
}
} else {
deduped.push(candidate);
}
}
*candidates = deduped;
}
fn overlay_for_name<'o>(
spec: &Spec<'_>,
split: &Split,
position: &Position<'_>,
overlays: &'o [CompletionOverlay<'_>],
name: &str,
) -> Option<&'o CompletionOverlay<'o>> {
if let Some(overlay) = overlay_at_cursor(spec, split, position, overlays)
.filter(|overlay| overlay.value.eq_ignore_ascii_case(name))
{
return Some(overlay);
}
let chain = metadata_chain_on_route(spec, position)?;
let owner = chain.last()?;
let path: Vec<&str> = chain.iter().skip(1).map(|meta| meta.cmd.name).collect();
overlays.iter().rev().find(|overlay| {
overlay.value.eq_ignore_ascii_case(name) && overlay.command.matches(owner, &path)
})
}
fn overlay_at_cursor<'o>(
spec: &Spec<'_>,
split: &Split,
position: &Position<'_>,
overlays: &'o [CompletionOverlay<'_>],
) -> Option<&'o CompletionOverlay<'o>> {
let attached = attached_long_value(position, &split.prefix);
if split.cword == 0
|| position.external.is_some()
|| (position.awaiting_value.is_none()
&& attached.is_none()
&& position.flags_possible
&& split.prefix.starts_with('-'))
{
return None;
}
let meta = metadata_chain_on_route(spec, position).and_then(|chain| chain.last().copied());
let target = if restarted(meta, split) {
meta.and_then(|owner| {
owner.args.first().map(|field| {
(
owner,
field.arg.name,
field.arg.double_dash == crate::DoubleDash::Required,
)
})
})
} else if let Some(flag) = position
.awaiting_value
.or_else(|| attached.map(|(flag, _, _)| flag))
{
flag_meta_owner_on_route(spec, position, flag)
.map(|(owner, field)| (owner, field.value_name.unwrap_or(field.flag.name), false))
} else {
position
.next_arg
.and_then(|arg| arg_meta_owner_on_route(spec, position, arg))
.map(|(owner, field)| {
(
owner,
field.arg.name,
field.arg.double_dash == crate::DoubleDash::Required,
)
})
.or_else(|| {
default_subcommand_arg(spec, split, position).map(|(owner, field)| {
(
owner,
field.arg.name,
field.arg.double_dash == crate::DoubleDash::Required,
)
})
})
};
let (owner, value, needs_separator) = target?;
if needs_separator && !position.separator_seen {
return None;
}
let chain = metadata_chain_on_route(spec, position)?;
let path: Vec<&str> = if let Some(owner_at) = chain
.iter()
.position(|candidate| core::ptr::eq(*candidate, owner))
{
chain[..=owner_at]
.iter()
.skip(1)
.map(|meta| meta.cmd.name)
.collect()
} else if core::ptr::eq(position.cmd, spec.root.cmd)
&& spec
.root
.subcommands
.iter()
.any(|candidate| core::ptr::eq(*candidate, owner))
{
vec![owner.cmd.name]
} else {
return None;
};
overlays.iter().rev().find(|overlay| {
overlay.value.eq_ignore_ascii_case(value) && overlay.command.matches(owner, &path)
})
}
fn flag_meta_owner_on_route<'a>(
spec: &Spec<'a>,
position: &Position<'_>,
flag: &Flag<'_>,
) -> Option<(&'a CommandMeta<'a>, &'a FlagMeta<'a>)> {
let chain = metadata_chain_on_route(spec, position)?;
chain.iter().rev().find_map(|owner| {
owner
.flags
.iter()
.find(|field| core::ptr::eq(field.flag, flag))
.map(|field| (*owner, field))
})
}
fn arg_meta_owner_on_route<'a>(
spec: &Spec<'a>,
position: &Position<'_>,
arg: &Arg<'_>,
) -> Option<(&'a CommandMeta<'a>, &'a ArgMeta<'a>)> {
let chain = metadata_chain_on_route(spec, position)?;
chain.iter().rev().find_map(|owner| {
owner
.args
.iter()
.find(|field| core::ptr::eq(field.arg, arg))
.map(|field| (*owner, field))
})
}
fn default_subcommand_arg<'a>(
spec: &Spec<'a>,
split: &Split,
position: &Position<'_>,
) -> Option<(&'a CommandMeta<'a>, &'a ArgMeta<'a>)> {
if !core::ptr::eq(position.cmd, spec.root.cmd) || position.help_topic || split.cword == 0 {
return None;
}
let default = spec.default_subcommand?;
let subcommands = || spec.root.subcommands.iter().copied();
subcommands()
.find(|sub| sub.cmd.name == default)
.or_else(|| subcommands().find(|sub| sub.cmd.aliases.contains(&default)))
.and_then(|sub| sub.args.first().map(|field| (sub, field)))
}
fn metadata_chain_on_route<'a>(
spec: &Spec<'a>,
position: &Position<'_>,
) -> Option<Vec<&'a CommandMeta<'a>>> {
if position.path.is_empty() {
return crate::help::find(spec, position.cmd).map(|(_, chain)| chain);
}
let mut chain = vec![spec.root];
let mut current = spec.root;
for (command, _) in position.path.iter().skip(1) {
current = current
.subcommands
.iter()
.copied()
.find(|meta| core::ptr::eq(meta.cmd, *command))?;
chain.push(current);
}
Some(chain)
}
pub fn candidates<'a>(spec: &Spec<'a>, split: &Split) -> Vec<Candidate<'a>> {
candidates_inner(spec, split, None)
}
fn candidates_inner<'a>(
spec: &Spec<'a>,
split: &Split,
view: Option<&'a crate::spec::ViewMeta<'a>>,
) -> Vec<Candidate<'a>> {
let position = match view {
Some(view) => walk_view(spec.root.cmd, split.argv(), view),
None => walk(spec.root.cmd, split.argv()),
};
if position.external.is_some() {
return Vec::new();
}
let meta = metadata_chain_on_route(spec, &position).and_then(|chain| chain.last().copied());
let token = split.prefix.as_str();
let mut out = if position.flags_possible && token == "-" {
let mut both = short_flags(spec, &position, "");
both.extend(long_flags(spec, &position, ""));
both
} else if position.flags_possible && token.starts_with("--") {
match attached_long_value(&position, token) {
Some((flag, form, value_prefix)) => flag_meta(spec.root, flag)
.map(|meta| {
let mut found = declared(
meta.choices,
meta.choice_details,
meta.complete,
split,
&position,
value_prefix,
);
attach_candidates(&mut found, form);
found
})
.unwrap_or_default(),
None => long_flags(spec, &position, token),
}
} else if position.flags_possible && token.starts_with('-') {
short_flags(spec, &position, token)
} else if restarted(meta, split) {
meta.and_then(|m| m.args.first())
.map(|m| positional(m, &position, split, token))
.unwrap_or_default()
} else if let Some(flag) = position.awaiting_value {
flag_meta(spec.root, flag)
.map(|m| {
declared(
m.choices,
m.choice_details,
m.complete,
split,
&position,
token,
)
})
.unwrap_or_default()
} else {
let mut found = Vec::new();
if let Some(arg) = position.next_arg {
if let Some(m) = arg_meta(spec.root, arg) {
found.extend(positional(m, &position, split, token));
}
}
if let Some(meta) = meta {
found.extend(subcommands(meta, token));
}
if let Some((_, arg)) = default_subcommand_arg(spec, split, &position) {
found.extend(positional(arg, &position, split, token));
}
found
};
sort_and_dedup_candidates(&mut out);
out
}
fn attached_long_value<'t, 'p>(
position: &Position<'t>,
token: &'p str,
) -> Option<(&'t Flag<'t>, &'p str, &'p str)> {
let (form, prefix) = token.split_once('=')?;
let long = form.strip_prefix("--")?;
position
.flags
.iter()
.copied()
.find(|flag| (flag.takes_value || flag.bool_value) && flag.longs.contains(&long))
.map(|flag| (flag, form, prefix))
}
fn attach_candidates(candidates: &mut [Candidate<'_>], form: &str) {
for candidate in candidates {
candidate.value = format!("{form}={}", candidate.value);
}
}
fn restarted(meta: Option<&CommandMeta<'_>>, split: &Split) -> bool {
let Some(token) = meta.and_then(|m| m.restart_token) else {
return false;
};
split.cword > 0 && split.words[split.cword - 1] == token
}
fn subcommands<'a>(meta: &'a CommandMeta<'a>, token: &str) -> Vec<Candidate<'a>> {
let mut out = Vec::new();
for sub in meta.subcommands {
if sub.hide {
continue;
}
for name in core::iter::once(&sub.cmd.name).chain(sub.cmd.aliases.iter()) {
if sub.hidden_aliases.contains(name) {
continue;
}
if name.starts_with(token) {
out.push(Candidate {
value: (*name).to_string(),
kind: CandidateKind::Command,
display: None,
description: deprecated_description(
sub.about,
sub.deprecated,
sub.deprecated_warn_at,
sub.deprecated_remove_at,
),
});
}
}
}
out
}
fn long_flags<'a>(spec: &Spec<'a>, position: &Position<'_>, token: &str) -> Vec<Candidate<'a>> {
let mut out = Vec::new();
for flag in &position.flags {
let meta = flag_meta(spec.root, flag);
if meta.is_some_and(|m| m.hide) {
continue;
}
let description = meta.and_then(|m| {
deprecated_description(
m.help,
m.deprecated,
m.deprecated_warn_at,
m.deprecated_remove_at,
)
});
for long in flag.longs {
if meta.is_some_and(|m| m.hidden_longs.contains(long)) {
continue;
}
let value = format!("--{long}");
if value.starts_with(token) {
out.push(Candidate {
value,
kind: CandidateKind::Flag,
display: None,
description: description.clone(),
});
}
}
if let Some(negate) = flag.negate {
let value = format!("--{negate}");
if value.starts_with(token) {
out.push(Candidate {
value,
kind: CandidateKind::Flag,
display: None,
description: description.clone(),
});
}
}
}
out
}
fn short_flags<'a>(spec: &Spec<'a>, position: &Position<'_>, token: &str) -> Vec<Candidate<'a>> {
let wanted = token.as_bytes().get(1).copied();
let mut out = Vec::new();
for flag in &position.flags {
let meta = flag_meta(spec.root, flag);
if meta.is_some_and(|m| m.hide) {
continue;
}
for &short in flag.shorts {
if meta.is_some_and(|m| m.hidden_shorts.contains(&short)) {
continue;
}
let asked_about = match wanted {
None => true,
Some(letter) => letter == short,
};
if asked_about {
out.push(Candidate {
value: format!("-{}", short as char),
kind: CandidateKind::Flag,
display: None,
description: meta.and_then(|m| {
deprecated_description(
m.help,
m.deprecated,
m.deprecated_warn_at,
m.deprecated_remove_at,
)
}),
});
}
}
}
out
}
fn deprecated_description<'a>(
base: Option<&'a str>,
message: Option<&'a str>,
warn_at: Option<&'a str>,
remove_at: Option<&'a str>,
) -> Option<std::borrow::Cow<'a, str>> {
if message.is_none() && warn_at.is_none() && remove_at.is_none() {
return base.map(std::borrow::Cow::Borrowed);
}
let mut parts = Vec::new();
if let Some(message) = message {
parts.push(message.to_string());
}
if let Some(at) = warn_at {
parts.push(format!("warns at {at}"));
}
if let Some(at) = remove_at {
parts.push(format!("removed at {at}"));
}
let label = format!("[deprecated: {}]", parts.join("; "));
Some(std::borrow::Cow::Owned(match base {
Some(base) if !base.is_empty() => format!("{base} {label}"),
_ => label,
}))
}
fn positional<'a>(
meta: &'a ArgMeta<'a>,
position: &Position<'_>,
split: &Split,
token: &str,
) -> Vec<Candidate<'a>> {
if meta.arg.double_dash == crate::DoubleDash::Required && !position.separator_seen {
if token.is_empty() {
return vec![Candidate {
value: "--".to_string(),
kind: CandidateKind::Value,
display: None,
description: None,
}];
}
return Vec::new();
}
declared(
meta.choices,
meta.choice_details,
meta.complete,
split,
position,
token,
)
}
fn declared<'a>(
choices_declared: &'a [&'a str],
choice_details: &'a [crate::spec::ChoiceMeta<'a>],
completer: Option<Completer>,
split: &Split,
position: &Position<'_>,
token: &str,
) -> Vec<Candidate<'a>> {
if !choices_declared.is_empty() {
return choices(choices_declared, choice_details, token);
}
let Some(completer) = completer else {
return Vec::new();
};
let words = split.argv();
let path: Vec<(&Command<'_>, &[String])> = position
.path
.iter()
.map(|(cmd, start)| (*cmd, words.get(*start..).unwrap_or(&[])))
.collect();
let ctx = CompleteCtx {
words: &split.words,
cword: split.cword,
prefix: token,
command_words: command_words(split, position),
command_path: &path,
};
let mut found = completer(&ctx);
found.retain(|c| c.value.starts_with(token));
found
}
fn command_words<'s>(split: &'s Split, position: &Position<'_>) -> &'s [String] {
let words = split.argv();
words.get(position.command_start..).unwrap_or(&[])
}
fn choices<'a>(
declared: &'a [&'a str],
details: &'a [crate::spec::ChoiceMeta<'a>],
token: &str,
) -> Vec<Candidate<'a>> {
declared
.iter()
.filter(|c| c.starts_with(token))
.map(|c| Candidate {
value: (*c).to_string(),
kind: CandidateKind::Value,
display: None,
description: details
.iter()
.find(|detail| {
detail.value == *c || detail.aliases.iter().any(|alias| alias.value == *c)
})
.and_then(|detail| detail.help)
.map(::std::borrow::Cow::Borrowed),
})
.collect()
}
fn flag_meta<'a>(meta: &'a CommandMeta<'a>, flag: &Flag<'_>) -> Option<&'a FlagMeta<'a>> {
meta.flags
.iter()
.find(|m| core::ptr::eq(m.flag, flag))
.or_else(|| meta.subcommands.iter().find_map(|sub| flag_meta(sub, flag)))
}
fn arg_meta<'a>(meta: &'a CommandMeta<'a>, arg: &Arg<'_>) -> Option<&'a ArgMeta<'a>> {
meta.args
.iter()
.find(|m| core::ptr::eq(m.arg, arg))
.or_else(|| meta.subcommands.iter().find_map(|sub| arg_meta(sub, arg)))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum Shell {
Bash,
Elvish,
Zsh,
Fish,
Nu,
PowerShell,
}
impl Shell {
pub fn as_str(self) -> &'static str {
match self {
Shell::Bash => "bash",
Shell::Elvish => "elvish",
Shell::Zsh => "zsh",
Shell::Fish => "fish",
Shell::Nu => "nu",
Shell::PowerShell => "powershell",
}
}
pub fn from_name(name: &str) -> Option<Self> {
match name {
"bash" => Some(Shell::Bash),
"elvish" => Some(Shell::Elvish),
"zsh" => Some(Shell::Zsh),
"fish" => Some(Shell::Fish),
"nu" | "nushell" => Some(Shell::Nu),
"powershell" | "pwsh" => Some(Shell::PowerShell),
_ => None,
}
}
fn backtick_escapes(self) -> bool {
matches!(self, Shell::PowerShell)
}
fn doubles_quotes(self) -> bool {
matches!(self, Shell::PowerShell)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Split {
pub words: Vec<String>,
pub cword: usize,
pub prefix: String,
}
impl Split {
pub fn walked(&self) -> &[String] {
&self.words[..=self.cword]
}
pub fn argv(&self) -> &[String] {
let start = 1.min(self.cword);
&self.words[start..self.cword]
}
}
pub fn split(line: &str, cursor: usize, shell: Shell) -> Split {
let cursor = floor_char_boundary(line, cursor.min(line.len()));
let mut words: Vec<String> = Vec::new();
let mut word = String::new();
let mut started = false;
let mut cword = None;
let mut prefix = None;
let mut cursor_in_word = false;
let mut chars = line.char_indices().peekable();
let mut quote: Option<char> = None;
macro_rules! reached {
($idx:expr) => {
if $idx == cursor && prefix.is_none() {
cword = Some(words.len());
prefix = Some(word.clone());
cursor_in_word = started;
}
};
}
while let Some((i, c)) = chars.next() {
reached!(i);
match quote {
Some('\'') => {
if c == '\'' {
if shell.doubles_quotes() && chars.peek().map(|&(_, n)| n) == Some('\'') {
reached!(chars.peek().expect("peeked just above").0);
word.push('\'');
chars.next();
} else {
quote = None;
}
} else {
word.push(c);
}
}
Some(q) => {
if c == q {
if shell.doubles_quotes() && chars.peek().map(|&(_, n)| n) == Some(q) {
reached!(chars.peek().expect("peeked just above").0);
word.push(q);
chars.next();
} else {
quote = None;
}
} else if is_escape(c, shell) {
match chars.peek() {
Some(&(j, next)) if escapable_in_quotes(next, shell) => {
reached!(j);
word.push(next);
chars.next();
}
_ => word.push(c),
}
} else {
word.push(c);
}
}
None => {
if c == '\'' || c == '"' {
quote = Some(c);
started = true;
} else if is_escape(c, shell) {
started = true;
if let Some(&(j, next)) = chars.peek() {
reached!(j);
word.push(next);
chars.next();
} else {
started = true;
}
} else if c.is_whitespace() {
if started {
words.push(core::mem::take(&mut word));
started = false;
}
} else {
word.push(c);
started = true;
}
}
}
}
if prefix.is_none() {
cword = Some(words.len());
prefix = Some(word.clone());
cursor_in_word = started;
}
if started {
words.push(word);
}
let cword = cword.unwrap_or(0);
if !cursor_in_word {
words.insert(cword, String::new());
}
Split {
words,
cword,
prefix: prefix.unwrap_or_default(),
}
}
fn is_escape(c: char, shell: Shell) -> bool {
if shell.backtick_escapes() {
c == '`'
} else {
c == '\\'
}
}
fn escapable_in_quotes(c: char, shell: Shell) -> bool {
if shell.backtick_escapes() {
matches!(c, '"' | '`' | '$')
} else {
matches!(c, '"' | '\\' | '$' | '`')
}
}
fn floor_char_boundary(s: &str, index: usize) -> usize {
let mut i = index;
while i > 0 && !s.is_char_boundary(i) {
i -= 1;
}
i
}
#[cfg(test)]
mod tests {
use super::*;
use std::task::{Context, Poll, Waker};
fn run_ready<F: Future>(future: F) -> F::Output {
let waker = Waker::noop();
let mut cx = Context::from_waker(waker);
let mut future = std::pin::pin!(future);
match future.as_mut().poll(&mut cx) {
Poll::Ready(output) => output,
Poll::Pending => panic!("test completion unexpectedly needed an executor wakeup"),
}
}
static GLOBAL: Flag = Flag {
key: 1,
name: "verbose",
longs: &["verbose"],
shorts: b"v",
global: true,
negate: Some("quiet"),
..Flag::BOOL
};
static JOBS: Flag = Flag {
key: 2,
name: "jobs",
longs: &["jobs"],
..Flag::VALUE
};
static TOOLS: Flag = Flag {
key: 8,
name: "tools",
longs: &["tools"],
variadic: true,
..Flag::VALUE
};
static TOOL: Arg = Arg {
key: 3,
name: "TOOL",
..Arg::REQUIRED
};
static USE: Command = Command {
name: "use",
aliases: &["u"],
flags: &[&JOBS, &TOOLS],
args: &[&TOOL],
..Command::EMPTY
};
static FORWARDED: Arg = Arg {
key: 4,
name: "ARGS",
double_dash: crate::DoubleDash::Automatic,
..Arg::VAR
};
static EXEC: Command = Command {
name: "exec",
args: &[&FORWARDED],
..Command::EMPTY
};
static LS: Command = Command {
name: "ls",
..Command::EMPTY
};
static FIRST: Arg = Arg {
key: 5,
name: "FIRST",
..Arg::REQUIRED
};
static SECOND: Arg = Arg {
key: 6,
name: "SECOND",
..Arg::REQUIRED
};
static TASK: Command = Command {
name: "task",
args: &[&FIRST, &SECOND],
..Command::EMPTY
};
static SCRIPT_ARG: Arg = Arg {
key: 13,
name: "FILE",
..Arg::REQUIRED
};
static MODE: Arg = Arg {
key: 14,
name: "MODE",
..Arg::REQUIRED
};
static SHIP: Command = Command {
name: "ship",
args: &[&MODE, &SCRIPT_ARG],
..Command::EMPTY
};
static FILE: Arg = Arg {
key: 9,
name: "FILE",
..Arg::REQUIRED
};
static PIPED: Arg = Arg {
key: 11,
name: "PATH",
double_dash: crate::DoubleDash::Required,
..Arg::REQUIRED
};
static FROM: Flag = Flag {
key: 12,
name: "from",
longs: &["from"],
..Flag::VALUE
};
static PIPE: Command = Command {
name: "pipe",
flags: &[&FROM],
args: &[&PIPED],
..Command::EMPTY
};
static EDIT: Command = Command {
name: "edit",
flags: &[&INTO],
args: &[&FILE],
..Command::EMPTY
};
static INTO: Flag = Flag {
key: 10,
name: "into",
longs: &["into"],
..Flag::VALUE
};
static AFTER: Arg = Arg {
key: 7,
name: "AFTER",
double_dash: crate::DoubleDash::Required,
..Arg::REQUIRED
};
static WRAP: Command = Command {
name: "wrap",
args: &[&AFTER],
..Command::EMPTY
};
static PLUGINS: Command = Command {
name: "plugins",
subcommands: &[&LS],
..Command::EMPTY
};
static ROOT: Command = Command {
name: "mise",
flags: &[&GLOBAL],
subcommands: &[&USE, &EXEC, &PLUGINS],
..Command::EMPTY
};
static SECRET: Command = Command {
name: "secret",
..Command::EMPTY
};
static LIST: Command = Command {
name: "list",
aliases: &["ls", "l"],
..Command::EMPTY
};
static META_LS: CommandMeta = CommandMeta {
cmd: &LS,
about: Some("List them"),
..CommandMeta::EMPTY
};
static META_PLUGINS: CommandMeta = CommandMeta {
cmd: &PLUGINS,
about: Some("Manage plugins"),
subcommands: &[&META_LS],
..CommandMeta::EMPTY
};
static META_USE: CommandMeta = CommandMeta {
cmd: &USE,
about: Some("Use a tool"),
flags: &[FlagMeta {
flag: &JOBS,
help: Some("How many at once"),
choices: &["1", "2", "4"],
choice_details: &[crate::spec::ChoiceMeta {
value: "2",
help: Some("Two workers"),
hide: false,
aliases: &[],
}],
..FlagMeta::EMPTY
}],
args: &[ArgMeta {
arg: &TOOL,
help: Some("Which tool"),
choices: &["node", "python"],
..ArgMeta::EMPTY
}],
..CommandMeta::EMPTY
};
fn tools(ctx: &CompleteCtx<'_>) -> Vec<Candidate<'static>> {
if ctx.previous() == Some("--only") {
return vec![Candidate::new("node")];
}
vec![
Candidate::described("node", "JavaScript"),
Candidate::described("python", "Snakes"),
Candidate::new("ruby"),
Candidate::new("ruby").displayed("Ruby runtime"),
]
}
static TOOL_ARG: Arg = Arg {
key: 15,
name: "TOOL",
..Arg::REQUIRED
};
static ONLY: Flag = Flag {
key: 16,
name: "only",
longs: &["only"],
..Flag::VALUE
};
fn sources(_ctx: &CompleteCtx<'_>) -> Vec<Candidate<'static>> {
vec![Candidate::new("upstream")]
}
static SOURCE: Flag = Flag {
key: 17,
name: "source",
longs: &["source"],
..Flag::VALUE
};
static INSTALL: Command = Command {
name: "install",
flags: &[&ONLY, &SOURCE],
args: &[&TOOL_ARG],
..Command::EMPTY
};
static META_INSTALL: CommandMeta = CommandMeta {
cmd: &INSTALL,
about: Some("Install a tool"),
flags: &[
FlagMeta {
flag: &ONLY,
help: Some("Just this one"),
complete: Some(tools),
..FlagMeta::EMPTY
},
FlagMeta {
flag: &SOURCE,
help: Some("Where from"),
complete: Some(sources),
..FlagMeta::EMPTY
},
],
args: &[ArgMeta {
arg: &TOOL_ARG,
help: Some("Which tool"),
complete: Some(tools),
..ArgMeta::EMPTY
}],
..CommandMeta::EMPTY
};
static META_SHIP: CommandMeta = CommandMeta {
cmd: &SHIP,
about: Some("Ship a file"),
restart_token: Some(":::"),
args: &[
ArgMeta {
arg: &MODE,
choices: &["fast", "slow"],
..ArgMeta::EMPTY
},
ArgMeta {
arg: &SCRIPT_ARG,
help: Some("Which file"),
..ArgMeta::EMPTY
},
],
..CommandMeta::EMPTY
};
static META_PIPE: CommandMeta = CommandMeta {
cmd: &PIPE,
about: Some("Pipe a file"),
flags: &[FlagMeta {
flag: &FROM,
help: Some("Read from"),
value_name: Some("FILE"),
..FlagMeta::EMPTY
}],
args: &[ArgMeta {
arg: &PIPED,
help: Some("Where from"),
..ArgMeta::EMPTY
}],
..CommandMeta::EMPTY
};
static META_EDIT: CommandMeta = CommandMeta {
cmd: &EDIT,
about: Some("Edit a file"),
flags: &[FlagMeta {
flag: &INTO,
help: Some("Where to write it"),
value_name: Some("DIR"),
..FlagMeta::EMPTY
}],
args: &[ArgMeta {
arg: &FILE,
help: Some("Which file"),
choices: &["mise.toml", "mise.local.toml"],
..ArgMeta::EMPTY
}],
..CommandMeta::EMPTY
};
static META_WRAP: CommandMeta = CommandMeta {
cmd: &WRAP,
about: Some("Wrap something"),
args: &[ArgMeta {
arg: &AFTER,
choices: &["red", "blue"],
..ArgMeta::EMPTY
}],
..CommandMeta::EMPTY
};
static META_TASK: CommandMeta = CommandMeta {
cmd: &TASK,
about: Some("Do two things"),
restart_token: Some(":::"),
args: &[
ArgMeta {
arg: &FIRST,
choices: &["one", "two"],
..ArgMeta::EMPTY
},
ArgMeta {
arg: &SECOND,
choices: &["alpha", "beta"],
..ArgMeta::EMPTY
},
],
..CommandMeta::EMPTY
};
static META_EXEC: CommandMeta = CommandMeta {
cmd: &EXEC,
about: Some("Run something"),
restart_token: Some(":::"),
args: &[ArgMeta {
arg: &FORWARDED,
help: Some("What to run"),
choices: &["one", "two"],
complete_type: Some("command_args"),
..ArgMeta::EMPTY
}],
..CommandMeta::EMPTY
};
static META_SECRET: CommandMeta = CommandMeta {
cmd: &SECRET,
about: Some("Not for you"),
hide: true,
..CommandMeta::EMPTY
};
static META_LIST: CommandMeta = CommandMeta {
cmd: &LIST,
about: Some("List everything"),
hidden_aliases: &["l"],
..CommandMeta::EMPTY
};
static META_ROOT: CommandMeta = CommandMeta {
cmd: &ROOT_WITH_META,
flags: &[FlagMeta {
flag: &GLOBAL,
help: Some("Say more"),
..FlagMeta::EMPTY
}],
subcommands: &[
&META_USE,
&META_EXEC,
&META_PLUGINS,
&META_SECRET,
&META_LIST,
&META_TASK,
&META_WRAP,
&META_EDIT,
&META_PIPE,
&META_SHIP,
&META_INSTALL,
],
..CommandMeta::EMPTY
};
static ROOT_WITH_META: Command = Command {
name: "mise",
flags: &[&GLOBAL],
subcommands: &[
&USE, &EXEC, &PLUGINS, &SECRET, &LIST, &TASK, &WRAP, &EDIT, &PIPE, &SHIP, &INSTALL,
],
..Command::EMPTY
};
static SPEC: Spec = Spec {
name: "mise",
bin: Some("mise"),
root: &META_ROOT,
default_subcommand: Some("u"),
..Spec::EMPTY
};
fn offered(line: &str) -> Vec<String> {
candidates(&SPEC, &at_end(line))
.into_iter()
.map(|c| c.value)
.collect()
}
fn runtime_tools(ctx: CompleteCtx<'_>) -> CompletionFuture<'_> {
Box::pin(async move {
vec![Candidate::described(
format!("{}uby", ctx.prefix),
"from async runtime state",
)]
})
}
fn labeled_ruby(_ctx: &CompleteCtx<'_>) -> Vec<Candidate<'static>> {
vec![Candidate::new("ruby").displayed("Ruby runtime")]
}
static RUNTIME_COMPLETIONS: [CompletionOverlay<'static>; 1] =
[CompletionOverlay::asynchronous(
"use",
"tool",
runtime_tools,
)];
static LABELED_COMPLETIONS: [CompletionOverlay<'static>; 1] =
[CompletionOverlay::sync("install", "tool", labeled_ruby)];
static GLOBAL_RUNTIME_COMPLETIONS: [CompletionOverlay<'static>; 1] =
[CompletionOverlay::async_any("tool", runtime_tools)];
static FILE_RUNTIME_COMPLETIONS: [CompletionOverlay<'static>; 1] =
[CompletionOverlay::asynchronous(
"edit",
"file",
runtime_tools,
)];
static PIPE_RUNTIME_COMPLETIONS: [CompletionOverlay<'static>; 1] =
[CompletionOverlay::asynchronous(
"pipe",
"path",
runtime_tools,
)];
static FROM_RUNTIME_COMPLETIONS: [CompletionOverlay<'static>; 1] =
[CompletionOverlay::asynchronous(
"pipe",
"FILE",
runtime_tools,
)];
static PLUGIN_RUNTIME_COMPLETIONS: [CompletionOverlay<'static>; 1] =
[CompletionOverlay::asynchronous(
"plugins",
"tool",
runtime_tools,
)];
#[test]
fn a_failed_view_name_fallback_keeps_the_cursor_selected_completer() {
static DEEP_VIEW: crate::spec::ViewMeta = crate::spec::ViewMeta {
id: "installer",
name: "installer",
bin: "installer",
root: "install nested",
all_globals: false,
globals: &[],
};
let split = at_end("mise install r");
let position = walk(SPEC.root.cmd, split.argv());
let words = split.argv();
let command_path: Vec<(&Command<'_>, &[String])> = position
.path
.iter()
.map(|(cmd, start)| (*cmd, words.get(*start..).unwrap_or(&[])))
.collect();
let ctx = CompleteCtx {
words: &split.words,
cword: split.cword,
prefix: &split.prefix,
command_words: command_words(&split, &position),
command_path: &command_path,
};
let found = for_name_at(&SPEC, "tool", &ctx, &position, Some(&DEEP_VIEW))
.expect("the cursor-selected completer should survive fallback failure");
assert!(
found.iter().any(|candidate| candidate.value == "ruby"),
"{found:?}"
);
}
#[test]
fn async_overlays_run_only_for_the_field_and_projection_at_the_cursor() {
let split = at_end("mise use r");
let answer = run_ready(complete_with(&SPEC, &split, &RUNTIME_COMPLETIONS));
assert_eq!(
answer
.candidates
.iter()
.map(|candidate| candidate.value.as_str())
.collect::<Vec<_>>(),
["ruby"]
);
assert_eq!(answer.files, None);
let file = run_ready(complete_with(
&SPEC,
&at_end("mise edit "),
&FILE_RUNTIME_COMPLETIONS,
));
assert_eq!(file.files, Some(Files::Any));
let before_separator = run_ready(complete_with(
&SPEC,
&at_end("mise pipe "),
&PIPE_RUNTIME_COMPLETIONS,
));
assert!(
before_separator
.candidates
.iter()
.all(|candidate| candidate.value != "uby"),
"{before_separator:?}"
);
assert_eq!(before_separator.files, None);
let after_separator = run_ready(complete_with(
&SPEC,
&at_end("mise pipe -- "),
&PIPE_RUNTIME_COMPLETIONS,
));
assert!(
after_separator
.candidates
.iter()
.any(|candidate| candidate.value == "uby"),
"{after_separator:?}"
);
assert_eq!(after_separator.files, Some(Files::Any));
let attached_file = run_ready(complete_with(
&SPEC,
&at_end("mise pipe --from="),
&FROM_RUNTIME_COMPLETIONS,
));
assert!(
attached_file
.candidates
.iter()
.any(|candidate| candidate.value == "--from=uby"),
"{attached_file:?}"
);
assert_eq!(attached_file.files, Some(Files::Any));
let flag = run_ready(complete_with(
&SPEC,
&at_end("mise use -"),
&RUNTIME_COMPLETIONS,
));
assert!(
flag.candidates
.iter()
.all(|candidate| candidate.value != "-uby"),
"{flag:?}"
);
let implied = run_ready(complete_with(
&SPEC,
&at_end("mise r"),
&RUNTIME_COMPLETIONS,
));
assert!(
implied
.candidates
.iter()
.any(|candidate| candidate.value == "ruby"),
"{implied:?}"
);
static FILE_DEFAULT_SPEC: Spec = Spec {
name: "mise",
bin: Some("mise"),
root: &META_ROOT,
default_subcommand: Some("edit"),
..Spec::EMPTY
};
let implied_file = run_ready(complete_with(
&FILE_DEFAULT_SPEC,
&at_end("mise m"),
&FILE_RUNTIME_COMPLETIONS,
));
assert_eq!(implied_file.files, Some(Files::Any));
let named = run_ready(complete_named_with(
&SPEC,
&at_end("mise plugins "),
&GLOBAL_RUNTIME_COMPLETIONS,
"tool",
));
assert!(
named
.candidates
.iter()
.any(|candidate| candidate.value == "uby"),
"{named:?}"
);
let named_at_root = run_ready(complete_named_with(
&SPEC,
&at_end("mise "),
&GLOBAL_RUNTIME_COMPLETIONS,
"tool",
));
assert!(
named_at_root
.candidates
.iter()
.any(|candidate| candidate.value == "uby"),
"{named_at_root:?}"
);
let named_on_owner = run_ready(complete_named_with(
&SPEC,
&at_end("mise plugins "),
&PLUGIN_RUNTIME_COMPLETIONS,
"tool",
));
assert!(
named_on_owner
.candidates
.iter()
.any(|candidate| candidate.value == "uby"),
"{named_on_owner:?}"
);
let named_on_descendant = run_ready(complete_named_with(
&SPEC,
&at_end("mise plugins ls "),
&PLUGIN_RUNTIME_COMPLETIONS,
"tool",
));
assert!(
named_on_descendant
.candidates
.iter()
.all(|candidate| candidate.value != "uby"),
"{named_on_descendant:?}"
);
let argv = [
OsString::from("__complete_word__"),
OsString::from("--shell"),
OsString::from("bash"),
OsString::from("--line"),
OsString::from("miser r"),
];
let rendered = run_ready(
SPEC.view()
.name("miser")
.bin("miser")
.completion_app()
.completions(&RUNTIME_COMPLETIONS)
.project("use")
.completion_request(&argv),
);
assert_eq!(rendered.as_deref(), Some("ruby\n"));
let binary_argv = [
OsString::from("__complete_word__"),
OsString::from("--shell"),
OsString::from("bash"),
OsString::from("--line"),
OsString::from("mis"),
];
let without_projection = run_ready(
SPEC.view()
.completion_app()
.completion_request(&binary_argv),
);
let with_projection = run_ready(
SPEC.view()
.completion_app()
.project("use")
.completion_request(&binary_argv),
);
assert_eq!(with_projection, without_projection);
static ROOT_ARG: Command = Command {
name: "root-arg",
args: &[&TOOL],
..Command::EMPTY
};
static ROOT_ARG_META: CommandMeta = CommandMeta {
cmd: &ROOT_ARG,
args: &[ArgMeta {
arg: &TOOL,
..ArgMeta::EMPTY
}],
..CommandMeta::EMPTY
};
static ROOT_ARG_SPEC: Spec = Spec {
name: "root-arg",
root: &ROOT_ARG_META,
..Spec::EMPTY
};
let argv0 = run_ready(complete_with(
&ROOT_ARG_SPEC,
&at_end("root"),
&GLOBAL_RUNTIME_COMPLETIONS,
));
assert!(
argv0
.candidates
.iter()
.all(|candidate| candidate.value != "rootuby"),
"{argv0:?}"
);
let unrelated = at_end("mise plugins ");
let answer = run_ready(complete_with(&SPEC, &unrelated, &RUNTIME_COMPLETIONS));
assert_eq!(answer.candidates[0].value, "ls");
}
#[test]
fn cursor_completion_keeps_presentation_metadata_from_duplicate_values() {
let answer = run_ready(complete_with(
&SPEC,
&at_end("mise install r"),
&LABELED_COMPLETIONS,
));
assert_eq!(answer.candidates.len(), 1, "{answer:?}");
assert_eq!(answer.candidates[0].value, "ruby");
assert_eq!(
answer.candidates[0].display.as_deref(),
Some("Ruby runtime")
);
}
#[test]
fn named_completion_keeps_presentation_metadata_from_duplicate_values() {
let answer = run_ready(complete_named_with(
&SPEC,
&at_end("mise install r"),
&LABELED_COMPLETIONS,
"tool",
));
assert_eq!(answer.candidates.len(), 1, "{answer:?}");
assert_eq!(answer.candidates[0].value, "ruby");
assert_eq!(
answer.candidates[0].display.as_deref(),
Some("Ruby runtime")
);
}
fn position_at(line: &str) -> Position<'static> {
let s = at_end(line);
walk(&ROOT, s.argv())
}
#[test]
fn the_cursor_is_in_the_command_the_words_reached() {
assert_eq!(position_at("mise ").cmd.name, "mise");
assert_eq!(position_at("mise plugins ").cmd.name, "plugins");
assert_eq!(position_at("mise plugins ls ").cmd.name, "ls");
assert_eq!(position_at("mise plug").cmd.name, "mise");
}
#[test]
fn a_flag_that_takes_a_value_puts_the_cursor_in_it() {
let p = position_at("mise use --jobs ");
assert_eq!(p.awaiting_value.map(|f| f.name), Some("jobs"));
assert!(position_at("mise use --jobs 4 ").awaiting_value.is_none());
assert!(position_at("mise --verbose ").awaiting_value.is_none());
}
#[test]
fn declared_builtin_actions_do_not_turn_completion_into_a_help_topic() {
static ASSIST: Flag = Flag {
key: 100,
name: "assist",
longs: &["assist"],
action: crate::ArgAction::Help,
..Flag::BOOL
};
static REVISION: Flag = Flag {
key: 101,
name: "revision",
longs: &["revision"],
action: crate::ArgAction::Version,
..Flag::BOOL
};
static TARGET: Arg = Arg {
key: 102,
name: "TARGET",
..Arg::REQUIRED
};
static APP: Command = Command {
name: "app",
flags: &[&ASSIST, &REVISION],
args: &[&TARGET],
..Command::EMPTY
};
for action in ["--assist", "--revision"] {
let position = walk(&APP, &[action.to_string(), "filled".to_string()]);
assert!(!position.help_topic, "{action} became a help topic");
assert_eq!(position.next_arg, None, "{action} stopped the walk early");
assert!(position.flags_possible);
}
}
#[test]
fn a_variadic_flag_still_claiming_words_holds_the_cursor() {
let p = position_at("mise use --tools node ");
assert_eq!(p.awaiting_value.map(|f| f.name), Some("tools"));
let p = position_at("mise use --tools node -- ");
assert!(p.awaiting_value.is_none());
}
#[test]
fn the_next_positional_is_the_one_a_word_would_fill() {
assert_eq!(
position_at("mise use ").next_arg.map(|a| a.name),
Some("TOOL")
);
assert!(position_at("mise use node ").next_arg.is_none());
assert_eq!(
position_at("mise exec a b ").next_arg.map(|a| a.name),
Some("ARGS")
);
}
#[test]
fn flags_stop_being_possible_where_the_parser_stops_reading_them() {
assert!(position_at("mise use ").flags_possible);
assert!(!position_at("mise use -- ").flags_possible);
assert!(!position_at("mise exec node ").flags_possible);
assert!(position_at("mise exec ").flags_possible);
}
#[test]
fn a_help_topic_puts_the_cursor_under_the_command_it_named() {
let p = position_at("mise help plugins ");
assert_eq!(p.cmd.name, "plugins");
assert!(!p.flags_possible, "a topic takes no flags");
assert!(p.next_arg.is_none(), "and fills no argument");
assert_eq!(position_at("mise help ").cmd.name, "mise");
}
#[test]
fn a_global_flag_is_in_scope_inside_a_subcommand() {
let names: Vec<_> = {
let argv = [std::ffi::OsStr::new("plugins")];
let mut parser = Parser::new(&ROOT, &argv);
while parser.next_event().is_some() {}
parser.flags_in_scope().map(|f| f.name).collect()
};
assert!(names.contains(&"verbose"), "{names:?}");
}
fn at_end(line: &str) -> Split {
split(line, line.len(), Shell::Bash)
}
#[test]
fn a_line_splits_into_the_words_the_shell_would_have_passed() {
let s = at_end("mise use node");
assert_eq!(s.words, ["mise", "use", "node"]);
assert_eq!(s.cword, 2);
assert_eq!(s.prefix, "node");
}
#[test]
fn a_cursor_after_a_space_is_completing_a_word_that_does_not_exist_yet() {
let s = at_end("mise use ");
assert_eq!(s.words, ["mise", "use", ""]);
assert_eq!(s.cword, 2);
assert_eq!(s.prefix, "");
}
#[test]
fn a_cursor_inside_a_word_completes_that_word_and_keeps_the_rest_of_the_line() {
let line = "mise use node";
let s = split(line, 7, Shell::Bash);
assert_eq!(s.words, ["mise", "use", "node"]);
assert_eq!(s.cword, 1);
assert_eq!(s.prefix, "us");
assert_eq!(s.walked(), ["mise", "use"]);
}
#[test]
fn a_quoted_space_stays_inside_its_word() {
let s = at_end(r#"mise run "my task"#);
assert_eq!(s.words, ["mise", "run", "my task"]);
assert_eq!(s.prefix, "my task");
let s = at_end("mise run 'my task");
assert_eq!(s.words, ["mise", "run", "my task"]);
assert_eq!(s.prefix, "my task");
}
#[test]
fn an_empty_quote_is_a_word() {
let s = at_end(r#"mise run "" "#);
assert_eq!(s.words, ["mise", "run", "", ""]);
assert_eq!(s.cword, 3);
}
#[test]
fn a_backslash_escapes_the_character_after_it() {
let s = at_end(r"mise run my\ task");
assert_eq!(s.words, ["mise", "run", "my task"]);
let s = at_end(r"mise run my\");
assert_eq!(s.words, ["mise", "run", "my"]);
assert_eq!(s.prefix, "my");
}
#[test]
fn a_single_quote_keeps_a_backslash_literal() {
let s = at_end(r"mise use 'C:\Users\me");
assert_eq!(s.words, ["mise", "use", r"C:\Users\me"]);
let s = at_end(r#"mise use "C:\Users\me"#);
assert_eq!(s.words, ["mise", "use", r"C:\Users\me"]);
let s = at_end(r#"mise use "say \"hi"#);
assert_eq!(s.words, ["mise", "use", r#"say "hi"#]);
}
#[test]
fn powershell_escapes_with_a_backtick() {
let s = split("mise run my` task", 17, Shell::PowerShell);
assert_eq!(s.words, ["mise", "run", "my task"]);
let s = split(r"mise use C:\Users\me", 20, Shell::PowerShell);
assert_eq!(s.words, ["mise", "use", r"C:\Users\me"]);
}
#[test]
fn a_cursor_in_a_gap_completes_a_word_that_is_not_there_yet() {
let s = split("mise use", 5, Shell::Bash);
assert_eq!(s.words, ["mise", "", "use"]);
assert_eq!(s.cword, 1);
assert_eq!(s.prefix, "");
assert_eq!(s.walked(), ["mise", ""]);
let s = split("mise use", 6, Shell::Bash);
assert_eq!(s.words, ["mise", "", "use"]);
assert_eq!(s.cword, 1);
}
#[test]
fn a_cursor_on_an_escaped_character_is_still_in_its_word() {
let line = r"mise run my\ task and more";
let s = split(line, 12, Shell::Bash);
assert_eq!(s.cword, 2);
assert_eq!(s.prefix, "my");
assert_eq!(s.words, ["mise", "run", "my task", "and", "more"]);
let line = r#"mise run "say \"hi" then"#;
let s = split(line, 15, Shell::Bash);
assert_eq!(s.cword, 2);
assert_eq!(s.prefix, "say ");
}
#[test]
fn powershell_writes_a_quote_by_doubling_it() {
let s = split("mise run 'it''s here", 20, Shell::PowerShell);
assert_eq!(s.words, ["mise", "run", "it's here"]);
assert_eq!(s.prefix, "it's here");
let s = split(r#"mise run "say ""hi"#, 18, Shell::PowerShell);
assert_eq!(s.words, ["mise", "run", r#"say "hi"#]);
let s = split("mise run 'it''s", 15, Shell::Bash);
assert_eq!(s.words, ["mise", "run", "its"]);
let s = split(r#"mise run "it""s"#, 15, Shell::Bash);
assert_eq!(s.words, ["mise", "run", "its"]);
}
#[test]
fn an_empty_line_is_still_completing_something() {
let s = at_end("");
assert_eq!(s.words, [""]);
assert_eq!(s.cword, 0);
assert_eq!(s.prefix, "");
assert_eq!(s.walked(), [""]);
}
#[test]
fn a_cursor_off_the_end_or_inside_a_character_lands_somewhere_sensible() {
let s = split("mise use", 999, Shell::Bash);
assert_eq!(s.prefix, "use");
let line = "mise ünicode";
let s = split(line, 7, Shell::Bash);
assert_eq!(s.cword, 1);
assert_eq!(s.prefix, "ü");
let s = split(line, 6, Shell::Bash);
assert_eq!(s.cword, 1);
assert_eq!(s.prefix, "");
}
#[test]
fn a_word_that_could_name_a_command_offers_the_commands() {
assert_eq!(
offered("mise "),
[
"edit", "exec", "install", "list", "ls", "node", "pipe", "plugins", "python",
"ship", "task", "u", "use", "wrap",
],
"sorted, and a hidden command is offered under none of its names"
);
assert_eq!(offered("mise pl"), ["plugins"]);
assert_eq!(offered("mise l"), ["list", "ls"]);
assert_eq!(offered("mise plugins "), ["ls"]);
}
#[test]
fn a_dash_offers_the_flags_that_would_be_accepted_there() {
assert_eq!(offered("mise -"), ["--quiet", "--verbose", "-v"]);
assert_eq!(offered("mise --"), ["--quiet", "--verbose"]);
assert_eq!(
offered("mise use --"),
["--jobs", "--quiet", "--tools", "--verbose"]
);
assert_eq!(offered("mise use -v"), ["-v"]);
assert!(
offered("mise use -j").is_empty(),
"--jobs has no short form"
);
}
#[test]
fn a_flag_waiting_for_its_value_offers_that_flags_choices() {
assert_eq!(offered("mise use --jobs "), ["1", "2", "4"]);
assert_eq!(offered("mise use --jobs 2"), ["2"]);
assert!(!offered("mise use --jobs ").contains(&"node".to_string()));
}
#[test]
fn a_positional_offers_its_choices() {
assert_eq!(offered("mise use "), ["node", "python"]);
assert_eq!(offered("mise use p"), ["python"]);
}
#[test]
fn past_a_separator_a_dash_is_not_a_flag() {
assert!(offered("mise use -- -").is_empty());
assert_eq!(
offered("mise -- "),
[
"edit", "exec", "install", "list", "ls", "node", "pipe", "plugins", "python",
"ship", "task", "u", "use", "wrap",
]
);
}
#[test]
fn a_candidate_carries_the_help_a_page_would_print() {
let found = candidates(&SPEC, &at_end("mise use --"));
let jobs = found.iter().find(|c| c.value == "--jobs").expect("--jobs");
assert_eq!(jobs.description.as_deref(), Some("How many at once"));
let found = candidates(&SPEC, &at_end("mise pl"));
assert_eq!(found[0].description.as_deref(), Some("Manage plugins"));
let found = candidates(&SPEC, &at_end("mise use --jobs "));
let two = found.iter().find(|c| c.value == "2").expect("choice 2");
assert_eq!(two.description.as_deref(), Some("Two workers"));
}
#[test]
fn a_help_topic_offers_the_commands_under_it() {
assert_eq!(offered("mise help plugins "), ["ls"]);
assert_eq!(
offered("mise help "),
[
"edit", "exec", "install", "list", "ls", "pipe", "plugins", "ship", "task", "u",
"use", "wrap"
]
);
}
#[test]
fn a_negation_is_offered_the_way_it_is_typed() {
assert!(offered("mise --").contains(&"--quiet".to_string()));
assert_eq!(offered("mise --q"), ["--quiet"]);
}
#[test]
fn a_restart_token_puts_the_cursor_back_at_the_first_argument() {
assert_eq!(offered("mise task one "), ["alpha", "beta"]);
assert_eq!(offered("mise task one ::: "), ["one", "two"]);
assert_eq!(offered("mise task one ::: t"), ["two"]);
}
#[test]
fn the_root_offers_what_the_command_it_falls_back_to_accepts() {
let found = offered("mise ");
assert!(found.contains(&"use".to_string()), "{found:?}");
assert!(found.contains(&"node".to_string()), "{found:?}");
assert!(found.contains(&"python".to_string()), "{found:?}");
assert_eq!(offered("mise n"), ["node"]);
assert!(!offered("mise plugins ").contains(&"node".to_string()));
}
#[test]
fn an_argument_that_needs_a_separator_offers_the_separator() {
assert_eq!(offered("mise wrap "), ["--"]);
assert!(offered("mise wrap r").is_empty());
assert_eq!(offered("mise wrap -- "), ["blue", "red"]);
assert_eq!(offered("mise wrap -- r"), ["red"]);
}
#[test]
fn the_fallback_command_is_found_by_any_name_it_answers_to() {
let found = offered("mise ");
assert!(found.contains(&"node".to_string()), "{found:?}");
}
#[test]
fn the_fallback_command_prefers_a_name_to_another_commands_alias() {
static A_ARG: Arg = Arg {
key: 90,
name: "a",
..Arg::REQUIRED
};
static B_ARG: Arg = Arg {
key: 91,
name: "b",
..Arg::REQUIRED
};
static ALPHA: Command = Command {
name: "alpha",
aliases: &["run"],
args: &[&A_ARG],
..Command::EMPTY
};
static PLAIN_RUN: Command = Command {
name: "run",
args: &[&B_ARG],
..Command::EMPTY
};
static META_ALPHA: CommandMeta = CommandMeta {
cmd: &ALPHA,
args: &[ArgMeta {
arg: &A_ARG,
choices: &["from-alpha"],
..ArgMeta::EMPTY
}],
..CommandMeta::EMPTY
};
static META_PLAIN_RUN: CommandMeta = CommandMeta {
cmd: &PLAIN_RUN,
args: &[ArgMeta {
arg: &B_ARG,
choices: &["from-run"],
..ArgMeta::EMPTY
}],
..CommandMeta::EMPTY
};
static EX: Command = Command {
name: "ex",
subcommands: &[&ALPHA, &PLAIN_RUN],
..Command::EMPTY
};
static META_EX: CommandMeta = CommandMeta {
cmd: &EX,
subcommands: &[&META_ALPHA, &META_PLAIN_RUN],
..CommandMeta::EMPTY
};
static EX_SPEC: Spec = Spec {
name: "ex",
bin: Some("ex"),
root: &META_EX,
default_subcommand: Some("run"),
..Spec::EMPTY
};
let found: Vec<String> = candidates(&EX_SPEC, &at_end("ex "))
.into_iter()
.map(|c| c.value)
.collect();
assert!(found.contains(&"from-run".to_string()), "{found:?}");
assert!(!found.contains(&"from-alpha".to_string()), "{found:?}");
}
fn answer(line: &str) -> Completions<'static> {
complete(&SPEC, &at_end(line))
}
#[test]
fn a_word_named_like_a_path_asks_the_shell_for_paths() {
assert_eq!(answer("mise edit ").files, Some(Files::Any));
assert_eq!(offered("mise edit "), ["mise.local.toml", "mise.toml"]);
assert_eq!(answer("mise edit --into ").files, Some(Files::Dirs));
}
#[test]
fn extension_types_preserve_the_filter_for_the_shell() {
assert_eq!(
declared_files("path:toml, .yaml,.", 0),
Some(Files::Extensions(vec![
"toml".to_string(),
"yaml".to_string()
]))
);
let answer = Completions {
candidates: Vec::new(),
files: declared_files("path:toml,yaml", 0),
};
assert_eq!(
render(&answer, Shell::Bash),
"\u{1}extensions\ttoml\tyaml\n"
);
}
#[test]
fn a_trace_exposes_the_parser_decision_behind_an_answer() {
let trace = trace(&SPEC, &at_end("mise use --jobs "));
assert_eq!(trace.command_path, ["mise", "use"]);
assert_eq!(trace.awaiting_value, Some("jobs"));
assert_eq!(trace.next_arg, Some("TOOL"));
assert_eq!(trace.files, None);
let rendered = trace.to_string();
assert!(rendered.contains("position: value of jobs"), "{rendered}");
assert!(rendered.contains("shell fallback: none"), "{rendered}");
}
#[test]
fn a_trace_uses_the_effective_argument_after_a_restart() {
let trace = trace(&SPEC, &at_end("mise ship fast script ::: "));
assert_eq!(trace.next_arg, Some("MODE"));
assert_eq!(trace.files, None);
assert!(trace.to_string().contains("position: argument MODE"));
}
#[test]
fn a_help_trace_keeps_the_selected_topic_path() {
let trace = trace(&SPEC, &at_end("mise help plugins "));
assert!(trace.help_topic);
assert_eq!(trace.command_path, ["mise", "plugins"]);
assert!(trace.to_string().contains("command: mise plugins"));
}
#[test]
fn a_help_trace_keeps_the_typed_route_to_a_shared_command() {
static CONFIG: Flag = Flag {
name: "config",
longs: &["config"],
global: true,
..Flag::VALUE
};
static SHARED: Command = Command {
name: "shared",
..Command::EMPTY
};
static LEFT: Command = Command {
name: "left",
subcommands: &[&SHARED],
..Command::EMPTY
};
static RIGHT: Command = Command {
name: "right",
subcommands: &[&SHARED],
..Command::EMPTY
};
static ROOT: Command = Command {
name: "ex",
flags: &[&CONFIG],
subcommands: &[&LEFT, &RIGHT],
..Command::EMPTY
};
static SHARED_META: CommandMeta = CommandMeta {
cmd: &SHARED,
..CommandMeta::EMPTY
};
static LEFT_META: CommandMeta = CommandMeta {
cmd: &LEFT,
subcommands: &[&SHARED_META],
..CommandMeta::EMPTY
};
static RIGHT_META: CommandMeta = CommandMeta {
cmd: &RIGHT,
subcommands: &[&SHARED_META],
..CommandMeta::EMPTY
};
static ROOT_META: CommandMeta = CommandMeta {
cmd: &ROOT,
flags: &[FlagMeta {
flag: &CONFIG,
..FlagMeta::EMPTY
}],
subcommands: &[&LEFT_META, &RIGHT_META],
..CommandMeta::EMPTY
};
static SHARED_SPEC: Spec = Spec {
name: "ex",
bin: Some("ex"),
root: &ROOT_META,
..Spec::EMPTY
};
static RIGHT_VIEW: crate::spec::ViewMeta = crate::spec::ViewMeta {
id: "right",
name: "right",
bin: "right",
root: "right",
all_globals: true,
globals: &[],
};
let trace = trace(&SHARED_SPEC, &at_end("ex help right shared "));
assert!(trace.help_topic);
assert_eq!(trace.command_path, ["ex", "right", "shared"]);
let trace = trace_view(
&SHARED_SPEC,
&at_end("right --config alpha help shared "),
&RIGHT_VIEW,
);
assert!(trace.help_topic);
assert_eq!(trace.command_path, ["ex", "right", "shared"]);
}
#[test]
fn a_trace_labels_a_dash_prefixed_cursor_as_a_flag_position() {
let trace = trace(&SPEC, &at_end("mise install --s"));
assert!(trace.flags_possible);
assert_eq!(trace.next_arg, Some("TOOL"));
assert!(trace.to_string().contains("position: flag"));
}
#[test]
fn executable_paths_and_command_names_are_distinct_shell_requests() {
assert_eq!(files_for("executable"), Some(Files::ExecutablePaths));
assert_eq!(files_for("command"), Some(Files::Commands));
}
#[test]
fn open_ended_value_hints_suppress_path_fallback() {
static URL: Arg = Arg {
key: 80,
name: "URL",
..Arg::REQUIRED
};
static ROOT: Command = Command {
name: "ex",
args: &[&URL],
..Command::EMPTY
};
static URL_META: CommandMeta = CommandMeta {
cmd: &ROOT,
args: &[ArgMeta {
arg: &URL,
complete_type: Some("url"),
..ArgMeta::EMPTY
}],
..CommandMeta::EMPTY
};
static URL_SPEC: Spec = Spec {
name: "ex",
bin: Some("ex"),
root: &URL_META,
..Spec::EMPTY
};
static UNKNOWN_META: CommandMeta = CommandMeta {
cmd: &ROOT,
args: &[ArgMeta {
arg: &URL,
complete_type: Some("unknown"),
..ArgMeta::EMPTY
}],
..CommandMeta::EMPTY
};
static UNKNOWN_SPEC: Spec = Spec {
name: "ex",
bin: Some("ex"),
root: &UNKNOWN_META,
..Spec::EMPTY
};
assert_eq!(complete(&URL_SPEC, &at_end("ex ")).files, None);
assert_eq!(
complete(&UNKNOWN_SPEC, &at_end("ex ")).files,
Some(Files::Any)
);
}
#[test]
fn a_position_that_knows_its_answers_does_not_ask_for_paths() {
assert_eq!(answer("mise use ").files, None);
assert_eq!(answer("mise plugins ").files, None);
assert_eq!(answer("mise use --").files, None);
assert_eq!(answer("mise -").files, None);
let a = answer("mise use nodx");
assert!(a.candidates.is_empty());
assert_eq!(a.files, None);
assert_eq!(answer("mise plugni").files, Some(Files::Any));
let a = answer("mise use --zzz");
assert!(a.candidates.is_empty());
assert_eq!(a.files, None);
assert_eq!(answer("mise help ").files, None);
}
#[test]
fn hidden_only_choices_still_close_the_position() {
static VALUE: Arg = Arg {
key: 90,
name: "VALUE",
..Arg::REQUIRED
};
static ROOT: Command = Command {
name: "hidden",
args: &[&VALUE],
..Command::EMPTY
};
static META: CommandMeta = CommandMeta {
cmd: &ROOT,
args: &[ArgMeta {
arg: &VALUE,
accepted_choices: &["secret"],
..ArgMeta::EMPTY
}],
..CommandMeta::EMPTY
};
static HIDDEN_SPEC: Spec = Spec {
name: "hidden",
bin: Some("hidden"),
root: &META,
..Spec::EMPTY
};
let answer = complete(&HIDDEN_SPEC, &at_end("hidden sec"));
assert!(answer.candidates.is_empty());
assert_eq!(answer.files, None);
}
#[test]
fn a_position_with_nothing_to_say_lets_the_shell_answer() {
let a = answer("mise edit some-file ");
assert!(a.candidates.is_empty(), "{:?}", a.candidates);
assert_eq!(a.files, Some(Files::Any));
}
#[test]
fn a_path_that_needs_a_separator_is_still_not_a_path_yet() {
let a = answer("mise pipe ");
assert_eq!(a.files, None);
assert_eq!(a.candidates.len(), 1, "the separator, and nothing else");
assert_eq!(a.candidates[0].value, "--");
assert_eq!(answer("mise pipe -- ").files, Some(Files::Any));
assert_eq!(answer("mise pipe --from ").files, Some(Files::Any));
assert_eq!(answer("mise pipe --from=").files, Some(Files::Any));
}
#[test]
fn an_argument_that_needs_a_separator_asks_for_nothing_else() {
let a = answer("mise wrap ");
assert_eq!(a.candidates.len(), 1);
assert_eq!(a.files, None);
}
#[test]
fn a_restart_asks_about_the_first_argument_for_paths_too() {
let first = answer("mise ship ");
assert_eq!(first.files, None, "MODE declares its set");
assert_eq!(
first
.candidates
.iter()
.map(|c| c.value.as_str())
.collect::<Vec<_>>(),
["fast", "slow"]
);
assert_eq!(
answer("mise ship fast ").files,
Some(Files::Any),
"FILE takes paths"
);
let after = answer("mise ship fast ::: ");
assert_eq!(after.files, None, "back at MODE, which declares its set");
assert_eq!(
after
.candidates
.iter()
.map(|c| c.value.as_str())
.collect::<Vec<_>>(),
["fast", "slow"]
);
let mistyped = answer("mise ship fast ::: zzz");
assert!(mistyped.candidates.is_empty());
assert_eq!(mistyped.files, None, "a mistyped choice is still a choice");
}
#[test]
fn a_restart_makes_command_args_expect_a_command_again() {
assert_eq!(answer("mise exec ").files, Some(Files::Commands));
assert_eq!(answer("mise exec one ").files, Some(Files::Any));
assert_eq!(answer("mise exec one ::: ").files, Some(Files::Commands));
}
#[test]
fn each_shell_is_written_the_way_it_reads() {
let answer = complete(&SPEC, &at_end("mise pl"));
assert_eq!(render(&answer, Shell::Bash), "plugins\n");
assert_eq!(render(&answer, Shell::Fish), "plugins\tManage plugins\n");
assert_eq!(
render(&answer, Shell::Elvish),
"plugins\tManage plugins\tplugins\n"
);
assert_eq!(
render(&answer, Shell::PowerShell),
"plugins\tManage plugins\tplugins\tcommand\n"
);
assert_eq!(
render(&answer, Shell::Zsh),
"plugins\tManage plugins\tplugins\n"
);
}
#[test]
fn a_shell_that_already_split_words_can_send_them_losslessly() {
let argv = [
OsString::from("__complete_word__"),
OsString::from("--shell"),
OsString::from("elvish"),
OsString::from("--words"),
OsString::from("mise"),
OsString::from("run"),
OsString::from("two words"),
];
let request = CompletionRequest::parse(&argv).expect("a completion request");
assert_eq!(request.shell, Shell::Elvish);
assert_eq!(request.split.words, ["mise", "run", "two words"]);
assert_eq!(request.split.cword, 2);
assert_eq!(request.split.prefix, "two words");
}
#[test]
fn display_text_does_not_change_what_the_shell_inserts() {
let answer = Completions {
candidates: vec![Candidate::described("iad", "US East").displayed("IAD · Virginia")],
files: None,
};
assert_eq!(render(&answer, Shell::Bash), "iad\n");
assert_eq!(render(&answer, Shell::Fish), "iad\tUS East\n");
assert_eq!(
render(&answer, Shell::PowerShell),
"iad\tUS East\tIAD · Virginia\tvalue\n"
);
assert_eq!(
render(&answer, Shell::Zsh),
"IAD · Virginia\tUS East\tiad\n"
);
}
#[test]
fn powershell_receives_native_candidate_kinds() {
let answer = Completions {
candidates: vec![Candidate::new("deploy").with_kind(CandidateKind::Command)],
files: None,
};
assert_eq!(
render(&answer, Shell::PowerShell),
"deploy\t\tdeploy\tcommand\n"
);
assert_eq!(render(&answer, Shell::Fish), "deploy\n");
}
#[test]
fn a_candidate_a_shell_could_not_read_is_quoted_for_zsh() {
static ODD: Command = Command {
name: "with space",
..Command::EMPTY
};
static ODD_META: CommandMeta = CommandMeta {
cmd: &ODD,
about: Some("Odd"),
..CommandMeta::EMPTY
};
static ODD_ROOT: Command = Command {
name: "ex",
subcommands: &[&ODD],
..Command::EMPTY
};
static ODD_ROOT_META: CommandMeta = CommandMeta {
cmd: &ODD_ROOT,
subcommands: &[&ODD_META],
..CommandMeta::EMPTY
};
static ODD_SPEC: Spec = Spec {
name: "ex",
bin: Some("ex"),
root: &ODD_ROOT_META,
..Spec::EMPTY
};
let answer = complete(&ODD_SPEC, &split("ex ", 3, Shell::Zsh));
let line = render(&answer, Shell::Zsh);
assert!(
line.starts_with("with space\tOdd\t'with space'"),
"{line:?}"
);
}
#[test]
fn the_marker_is_the_last_line_when_paths_belong() {
let answer = complete(&SPEC, &at_end("mise edit "));
let out = render(&answer, Shell::Bash);
assert_eq!(out.lines().last(), Some(FILES_MARKER));
assert!(out.starts_with("mise.local.toml\nmise.toml\n"), "{out:?}");
let answer = complete(&SPEC, &at_end("mise edit --into "));
assert_eq!(
render(&answer, Shell::Fish).lines().last(),
Some(DIRS_MARKER)
);
let answer = complete(&SPEC, &at_end("mise use "));
let out = render(&answer, Shell::Bash);
assert!(!out.contains('\u{1}'), "{out:?}");
}
#[test]
fn a_description_written_across_lines_stays_one_row() {
static WORDY: Command = Command {
name: "wordy",
..Command::EMPTY
};
static WORDY_META: CommandMeta = CommandMeta {
cmd: &WORDY,
about: Some("First line\nsecond line\n\nand a\ttab"),
..CommandMeta::EMPTY
};
static WORDY_ROOT: Command = Command {
name: "ex",
subcommands: &[&WORDY],
..Command::EMPTY
};
static WORDY_ROOT_META: CommandMeta = CommandMeta {
cmd: &WORDY_ROOT,
subcommands: &[&WORDY_META],
..CommandMeta::EMPTY
};
static WORDY_SPEC: Spec = Spec {
name: "ex",
bin: Some("ex"),
root: &WORDY_ROOT_META,
..Spec::EMPTY
};
let answer = complete(&WORDY_SPEC, &split("ex w", 4, Shell::Fish));
let out = render(&answer, Shell::Fish);
assert_eq!(out, "wordy\tFirst line second line and a tab\n");
assert_eq!(out.lines().count(), 1, "one candidate, one row");
let out = render(&answer, Shell::Zsh);
assert_eq!(out.matches('\t').count(), 2, "{out:?}");
}
#[test]
fn a_declared_completer_answers_for_its_value() {
assert_eq!(offered("mise install "), ["node", "python", "ruby"]);
let found = candidates(&SPEC, &at_end("mise install n"));
assert_eq!(found.len(), 1);
assert_eq!(found[0].value, "node");
assert_eq!(found[0].description.as_deref(), Some("JavaScript"));
let ruby = candidates(&SPEC, &at_end("mise install ru"));
assert_eq!(ruby.len(), 1, "{ruby:?}");
assert_eq!(ruby[0].display.as_deref(), Some("Ruby runtime"));
assert_eq!(offered("mise install --only "), ["node"]);
}
#[test]
fn a_completer_is_filtered_for_rather_than_by() {
assert_eq!(offered("mise install ru"), ["ruby"]);
assert!(offered("mise install zzz").is_empty());
}
#[test]
fn a_completer_can_read_the_line_it_was_called_about() {
assert_eq!(offered("mise install --only "), ["node"]);
assert_eq!(offered("mise install "), ["node", "python", "ruby"]);
}
#[test]
fn a_completer_that_says_nothing_leaves_the_position_open() {
let a = answer("mise install zzz");
assert!(a.candidates.is_empty());
assert_eq!(a.files, Some(Files::Any));
}
#[test]
fn an_attached_value_is_answered_by_its_flag() {
assert_eq!(offered("mise install --source="), ["--source=upstream"]);
assert_eq!(offered("mise install --source=u"), ["--source=upstream"]);
assert!(!offered("mise install --source=").contains(&"node".to_string()));
assert!(!offered("mise install -s").contains(&"node".to_string()));
assert_eq!(offered("mise install --source "), ["upstream"]);
assert_eq!(offered("mise install "), ["node", "python", "ruby"]);
}
static EXT_LS: Command = Command {
name: "ls",
..Command::EMPTY
};
static EXT_QUIET: Flag = Flag {
key: 40,
name: "quiet",
longs: &["quiet"],
..Flag::BOOL
};
static EXT_PLUGINS: Command = Command {
name: "plugins",
flags: &[&EXT_QUIET],
subcommands: &[&EXT_LS],
external_subcommand: true,
..Command::EMPTY
};
static EXT_ROOT: Command = Command {
name: "host",
subcommands: &[&EXT_PLUGINS],
..Command::EMPTY
};
static EXT_META_PLUGINS: CommandMeta = CommandMeta {
cmd: &EXT_PLUGINS,
subcommands: &[&CommandMeta {
cmd: &EXT_LS,
..CommandMeta::EMPTY
}],
..CommandMeta::EMPTY
};
static EXT_META: CommandMeta = CommandMeta {
cmd: &EXT_ROOT,
subcommands: &[&EXT_META_PLUGINS],
..CommandMeta::EMPTY
};
static EXT_SPEC: Spec = Spec {
name: "host",
root: &EXT_META,
..Spec::EMPTY
};
#[test]
fn a_walk_reports_where_the_words_left_these_tables() {
let split = at_end("host plugins myplugin ");
let position = walk(&EXT_ROOT, split.argv());
assert_eq!(position.external, Some(1), "{:?}", split.argv());
assert_eq!(position.cmd.name, "plugins", "the boundary's declarer");
let split = at_end("host plugins --quiet myplugin ");
assert_eq!(walk(&EXT_ROOT, split.argv()).external, Some(2));
let split = at_end("host plugins mypl");
assert_eq!(walk(&EXT_ROOT, split.argv()).external, None);
let split = at_end("host plugins ");
assert_eq!(walk(&EXT_ROOT, split.argv()).external, None);
let split = at_end("host plugins --nope ");
assert_eq!(walk(&EXT_ROOT, split.argv()).external, None);
}
#[test]
fn nothing_is_offered_past_an_external_boundary() {
let answer = complete(&EXT_SPEC, &at_end("host plugins myplugin "));
assert!(answer.candidates.is_empty(), "{answer:?}");
assert_eq!(answer.files, None, "no working directory either");
assert!(candidates(&EXT_SPEC, &at_end("host plugins myplugin ")).is_empty());
let flags = complete(&EXT_SPEC, &at_end("host plugins myplugin --"));
assert!(flags.candidates.is_empty(), "{flags:?}");
assert_eq!(flags.files, None);
let parent = complete(&EXT_SPEC, &at_end("host plugins "));
assert_eq!(
parent
.candidates
.iter()
.map(|candidate| candidate.value.as_str())
.collect::<Vec<_>>(),
["ls"],
"{parent:?}"
);
}
}