use crate::spec::{ArgMeta, CommandMeta, CommandSelector, FlagMeta, Spec, SpecView};
pub use crate::spec::{Candidate, 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 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);
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;
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(_) => {}
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(),
}
}
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(),
}
}
pub fn for_name<'a>(
spec: &'a 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: &'a 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: &'a 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();
out.dedup();
out
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Files {
Any,
Dirs,
ExecutablePaths,
Commands,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Completions<'a> {
pub candidates: Vec<Candidate<'a>>,
pub files: Option<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 = Request::parse(argv)?;
let mut split = request.split;
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;
}
}
let spec = self.view.spec();
let answer = if let Some(name) = request.candidates_for {
complete_named_with(&spec, &split, self.overlays, &name).await
} else {
complete_with(&spec, &split, self.overlays).await
};
Some(render(&answer, request.shell))
}
}
impl<'a> SpecView<'a> {
pub const fn completion_app(self) -> App<'a> {
App::new(self)
}
}
struct Request {
shell: Shell,
split: Split,
candidates_for: Option<String>,
}
impl Request {
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 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());
}
_ => {}
}
}
let cursor = cursor.unwrap_or(line.len());
Some(Self {
shell,
split: split(&line, cursor, shell),
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(&candidate.value);
out.push('\t');
out.push_str(description);
out.push('\t');
out.push_str(&zsh_quote(&candidate.value));
}
Shell::Fish | Shell::Nu | Shell::PowerShell => {
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');
}
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 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> {
if split.cword == 0
|| (position.awaiting_value.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()
&& at_cursor.is_some_and(|arg| arg.double_dash == crate::DoubleDash::Required)
&& !position.separator_seen
{
return None;
}
let (name, complete_type) = if let Some(flag) = position.awaiting_value {
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: &'a Spec<'a>, split: &Split) -> Completions<'a> {
complete_inner(spec, split, None)
}
pub fn complete_view<'a>(
spec: &'a Spec<'a>,
split: &Split,
view: &'a crate::spec::ViewMeta<'a>,
) -> Completions<'a> {
complete_inner(spec, split, Some(view))
}
fn complete_inner<'a>(
spec: &'a 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()),
};
let meta = metadata_chain_on_route(spec, &position).and_then(|chain| chain.last().copied());
let token = split.prefix.as_str();
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 = position.flags_possible && token.starts_with('-');
let (named, declares_choices, complete_type) = if let Some(flag) = position.awaiting_value {
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 = position.awaiting_value.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: &'a 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 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 mut dynamic = match overlay.handler {
CompletionHandler::Sync(completer) => completer(&ctx),
CompletionHandler::Async(completer) => completer(ctx).await,
};
dynamic.retain(|candidate| candidate.value.starts_with(&split.prefix));
let mut answer = complete(spec, split);
answer.candidates.extend(dynamic);
answer.candidates.sort();
answer
.candidates
.dedup_by(|left, right| left.value == right.value);
answer.files = declared_files_at_cursor(spec, split, &position);
answer
}
async fn complete_named_with<'a>(
spec: &'a Spec<'a>,
split: &Split,
overlays: &[CompletionOverlay<'_>],
name: &str,
) -> Completions<'a> {
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 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(&split.prefix));
candidates.sort();
candidates.dedup_by(|left, right| left.value == right.value);
Completions {
candidates,
files: None,
}
}
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>> {
if split.cword == 0
|| (position.awaiting_value.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 {
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: &'a 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: &'a 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: &'a 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: &'a 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: &'a Spec<'a>, split: &Split) -> Vec<Candidate<'a>> {
candidates_inner(spec, split, None)
}
fn candidates_inner<'a>(
spec: &'a 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()),
};
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("--") {
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
};
out.sort();
out.dedup_by(|a, b| a.value == b.value);
out
}
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(),
description: deprecated_description(
sub.about,
sub.deprecated,
sub.deprecated_warn_at,
sub.deprecated_remove_at,
),
});
}
}
}
out
}
fn long_flags<'a>(spec: &'a 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,
description: description.clone(),
});
}
}
if let Some(negate) = flag.negate {
let value = format!("--{negate}");
if value.starts_with(token) {
out.push(Candidate {
value,
description: description.clone(),
});
}
}
}
out
}
fn short_flags<'a>(spec: &'a 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),
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(),
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(),
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,
Zsh,
Fish,
Nu,
PowerShell,
}
impl Shell {
pub fn as_str(self) -> &'static str {
match self {
Shell::Bash => "bash",
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),
"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"),
]
}
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",
)]
})
}
static RUNTIME_COMPLETIONS: [CompletionOverlay<'static>; 1] =
[CompletionOverlay::asynchronous(
"use",
"tool",
runtime_tools,
)];
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 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 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");
}
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 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));
}
#[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::Zsh),
"plugins\tManage plugins\tplugins\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"));
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_not_answered_by_the_positional() {
assert!(!offered("mise install --source=").contains(&"node".to_string()));
assert!(!offered("mise install --source=").contains(&"upstream".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"]);
}
}