use core::fmt::Write as _;
use crate::spec::{CommandMeta, FlagMeta, Spec, ViewMeta};
use crate::{Command, Error};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum Code {
UnknownFlag,
MissingFlagValue,
UnexpectedArg,
ArgRequiresDoubleDash,
SubcommandConflict,
TooDeep,
MissingRequired,
DuplicateFlag,
InvalidChoice,
VarTooFew,
VarTooMany,
ConflictingFlags,
InvalidValue,
MissingGroup,
MissingSubcommand,
Help,
MissingArgsHelp,
HelpAll,
Version,
}
impl Code {
pub const fn as_str(self) -> &'static str {
match self {
Self::UnknownFlag => "unknown_flag",
Self::MissingFlagValue => "missing_flag_value",
Self::UnexpectedArg => "unexpected_arg",
Self::ArgRequiresDoubleDash => "arg_requires_double_dash",
Self::SubcommandConflict => "subcommand_conflict",
Self::TooDeep => "too_deep",
Self::MissingRequired => "missing_required",
Self::DuplicateFlag => "duplicate_flag",
Self::InvalidChoice => "invalid_choice",
Self::VarTooFew => "var_too_few",
Self::VarTooMany => "var_too_many",
Self::ConflictingFlags => "conflicting_flags",
Self::InvalidValue => "invalid_value",
Self::MissingGroup => "missing_group",
Self::MissingSubcommand => "missing_subcommand",
Self::Help => "help",
Self::MissingArgsHelp => "missing_args_help",
Self::HelpAll => "help_all",
Self::Version => "version",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ArgvSpan {
pub index: usize,
pub start: usize,
pub end: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Report {
pub code: Code,
pub subject: Option<String>,
pub location: Option<ArgvSpan>,
pub rendered: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Style {
coloured: bool,
}
impl Style {
pub const PLAIN: Style = Style { coloured: false };
pub const COLOURED: Style = Style { coloured: true };
pub fn auto() -> Style {
use std::io::IsTerminal as _;
let forced = std::env::var_os("CLICOLOR_FORCE").is_some_and(|v| v != "0");
let refused = std::env::var_os("NO_COLOR").is_some_and(|v| !v.is_empty());
if refused {
return Style::PLAIN;
}
if forced || std::io::stderr().is_terminal() {
Style::COLOURED
} else {
Style::PLAIN
}
}
fn wrap(self, code: &str, text: &str) -> String {
if self.coloured {
format!("\u{1b}[{code}m{text}\u{1b}[0m")
} else {
text.to_string()
}
}
fn error(self, text: &str) -> String {
self.wrap("1m\u{1b}[31", text)
}
fn warning(self, text: &str) -> String {
self.wrap("1m\u{1b}[33", text)
}
fn invalid(self, text: &str) -> String {
self.wrap("33", text)
}
fn valid(self, text: &str) -> String {
self.wrap("32", text)
}
fn heading(self, text: &str) -> String {
self.wrap("1m\u{1b}[4", text)
}
fn literal(self, text: &str) -> String {
self.wrap("1", text)
}
}
fn flag_named(token: &str) -> &str {
match token.strip_prefix("--") {
Some(body) => match body.find('=') {
Some(i) => &token[..i + 2],
None => token,
},
None => token,
}
}
fn long_spellings<'a>(meta: &'a crate::spec::FlagMeta<'a>) -> impl Iterator<Item = &'a str> {
meta.flag.longs.iter().copied().chain(meta.flag.negate)
}
fn flags_in_scope<'a, 'c>(
chain: &'c [&'a CommandMeta<'a>],
) -> impl Iterator<Item = &'a crate::spec::FlagMeta<'a>> + 'c {
let depth = chain.len();
chain.iter().enumerate().flat_map(move |(i, meta)| {
let own = i + 1 == depth;
meta.flags
.iter()
.filter(move |f| !f.hide && (own || f.flag.global))
})
}
fn jaro(a: &str, b: &str) -> f64 {
let (a, b): (Vec<char>, Vec<char>) = (a.chars().collect(), b.chars().collect());
if a.is_empty() && b.is_empty() {
return 1.0;
}
if a.is_empty() || b.is_empty() {
return 0.0;
}
let window = (a.len().max(b.len()) / 2).saturating_sub(1);
let mut a_matched = vec![false; a.len()];
let mut b_matched = vec![false; b.len()];
let mut matches = 0usize;
for (i, ch) in a.iter().enumerate() {
let start = i.saturating_sub(window);
let end = (i + window + 1).min(b.len());
for j in start..end {
if !b_matched[j] && b[j] == *ch {
a_matched[i] = true;
b_matched[j] = true;
matches += 1;
break;
}
}
}
if matches == 0 {
return 0.0;
}
let mut transpositions = 0usize;
let mut k = 0usize;
for (i, matched) in a_matched.iter().enumerate() {
if !matched {
continue;
}
while !b_matched[k] {
k += 1;
}
if a[i] != b[k] {
transpositions += 1;
}
k += 1;
}
let matches = matches as f64;
(matches / a.len() as f64
+ matches / b.len() as f64
+ (matches - transpositions as f64 / 2.0) / matches)
/ 3.0
}
fn nearest<'a>(typed: &str, candidates: impl Iterator<Item = &'a str>) -> Vec<&'a str> {
let mut scored: Vec<(f64, &str)> = candidates
.map(|candidate| (jaro(typed, candidate), candidate))
.filter(|(score, _)| *score > 0.7)
.collect();
scored.sort_unstable_by(|a, b| a.0.total_cmp(&b.0).then_with(|| a.1.cmp(b.1)));
scored.dedup_by(|a, b| a.1 == b.1);
scored.into_iter().map(|(_, candidate)| candidate).collect()
}
fn tip(style: Style, noun: &str, near: &[&str]) -> String {
match near {
[] => String::new(),
[one] => format!(
"\n {} a similar {noun} exists: '{}'\n",
style.valid("tip:"),
style.valid(one)
),
many => {
let listed: Vec<String> = many
.iter()
.map(|candidate| format!("'{}'", style.valid(candidate)))
.collect();
format!(
"\n {} some similar {noun}s exist: {}\n",
style.valid("tip:"),
listed.join(", ")
)
}
}
}
fn shown<'a>(meta: Option<&'a CommandMeta<'a>>, name: &str) -> String {
let Some(meta) = meta else {
return name.to_string();
};
if let Some(arg) = meta.args.iter().find(|a| a.arg.name == name) {
return crate::help::arg_usage(arg);
}
if let Some(flag) = meta
.flags
.iter()
.find(|f| f.flag.name == name || f.value_name == Some(name))
{
return crate::help::flag_spelling(flag);
}
name.to_string()
}
fn group_member_shown(meta: Option<&CommandMeta<'_>>, selector: &str) -> String {
let Some(meta) = meta else {
return selector.to_string();
};
let found = meta.flags.iter().find(|flag| {
flag.flag
.longs
.iter()
.any(|long| selector == format!("--{long}"))
|| flag
.flag
.shorts
.iter()
.any(|short| selector == format!("-{}", *short as char))
|| flag
.flag
.negate
.is_some_and(|negate| selector == format!("--{negate}"))
});
found
.map(|flag| {
let mut shown = crate::help::flag_spelling(flag);
if flag.flag.takes_value {
let name = flag.value_name.unwrap_or(flag.flag.name);
let _ = write!(shown, " <{name}>");
if flag.flag.variadic {
shown.push('…');
}
}
shown
})
.unwrap_or_else(|| selector.to_string())
}
fn value_bound_to(
root: &Command<'_>,
argv: &[&std::ffi::OsStr],
name: &str,
refused: &[&str],
view: Option<&ViewMeta<'_>>,
) -> Option<String> {
let mut parser = crate::Parser::new(root, argv);
if let Some(view) = view {
parser = parser.with_view(view);
}
let mut last = None;
while let Some(event) = parser.next_event() {
let value = match event {
Ok(crate::Event::Arg { arg, value, .. }) if arg.name == name => value,
Ok(crate::Event::Flag {
flag,
value: Some(value),
..
}) if flag.name == name => value,
Ok(_) => continue,
Err(_) => break,
};
let value = String::from_utf8_lossy(value).into_owned();
if !refused.is_empty() && !refused.contains(&value.as_str()) {
return Some(value);
}
last = Some(value);
}
last
}
fn path_taken<'t>(
root: &'t Command<'t>,
argv: &[&std::ffi::OsStr],
view: Option<&'t ViewMeta<'t>>,
) -> Vec<&'t Command<'t>> {
let mut path = vec![root];
let mut parser = crate::Parser::new(root, argv);
if let Some(view) = view {
parser = parser.with_view(view);
}
while let Some(event) = parser.next_event() {
match event {
Ok(crate::Event::Command(cmd)) => path.push(cmd),
Ok(_) => {}
Err(_) => break,
}
}
path
}
fn resolve<'a>(
spec: &'a Spec<'a>,
path: &[&Command<'_>],
) -> Option<(Vec<&'a str>, Vec<&'a CommandMeta<'a>>)> {
let mut names = vec![spec.bin.unwrap_or(spec.name)];
let mut chain = vec![spec.root];
for cmd in path.iter().skip(1) {
let here = chain.last()?;
let next = here
.subcommands
.iter()
.find(|sub| core::ptr::eq(sub.cmd, *cmd))?;
names.push(next.cmd.name);
chain.push(next);
}
Some((names, chain))
}
fn span_of_slice(argv: &[&std::ffi::OsStr], slice: &[u8]) -> Option<ArgvSpan> {
let start = slice.as_ptr() as usize;
let end = start.checked_add(slice.len())?;
argv.iter().enumerate().find_map(|(index, word)| {
let bytes = word.as_encoded_bytes();
let word_start = bytes.as_ptr() as usize;
let word_end = word_start.checked_add(bytes.len())?;
(start >= word_start && end <= word_end).then(|| ArgvSpan {
index,
start: start - word_start,
end: end - word_start,
})
})
}
fn value_span(
root: &Command<'_>,
argv: &[&std::ffi::OsStr],
name: &str,
wanted: impl Fn(&str) -> bool,
view: Option<&ViewMeta<'_>>,
) -> Option<ArgvSpan> {
let mut parser = crate::Parser::new(root, argv);
if let Some(view) = view {
parser = parser.with_view(view);
}
while let Some(event) = parser.next_event() {
let value = match event {
Ok(crate::Event::Arg { arg, value, .. }) if arg.name == name => value,
Ok(crate::Event::Flag {
flag,
value: Some(value),
..
}) if flag.name == name => value,
Ok(_) => continue,
Err(_) => break,
};
if wanted(&String::from_utf8_lossy(value)) {
return span_of_slice(argv, value);
}
}
None
}
fn flag_span(
root: &Command<'_>,
argv: &[&std::ffi::OsStr],
flag: &crate::Flag<'_>,
view: Option<&ViewMeta<'_>>,
) -> Option<ArgvSpan> {
let mut parser = crate::Parser::new(root, argv);
if let Some(view) = view {
parser = parser.with_view(view);
}
while let Some(event) = parser.next_event() {
let Err(Error::MissingFlagValue { flag: missing }) = event else {
continue;
};
if !core::ptr::eq(missing, flag) {
return None;
}
let index = parser.pos.checked_sub(1)?;
let bytes = argv.get(index)?.as_encoded_bytes();
if let Some(long) = bytes.strip_prefix(b"--") {
let name = long.split(|byte| *byte == b'=').next().unwrap_or(long);
return flag.longs.iter().find_map(|candidate| {
(name == candidate.as_bytes()).then_some(ArgvSpan {
index,
start: 0,
end: candidate.len() + 2,
})
});
}
let shorts = bytes.strip_prefix(b"-")?;
return shorts.iter().enumerate().find_map(|(offset, short)| {
flag.shorts.contains(short).then_some(ArgvSpan {
index,
start: offset + 1,
end: offset + 2,
})
});
}
None
}
fn code(error: &Error<'_, '_>) -> Code {
match error {
Error::UnknownFlag { .. } => Code::UnknownFlag,
Error::MissingFlagValue { .. } => Code::MissingFlagValue,
Error::UnexpectedArg { .. } => Code::UnexpectedArg,
Error::ArgRequiresDoubleDash { .. } => Code::ArgRequiresDoubleDash,
Error::SubcommandConflict { .. } => Code::SubcommandConflict,
Error::TooDeep => Code::TooDeep,
Error::MissingRequired { .. } => Code::MissingRequired,
Error::DuplicateFlag { .. } => Code::DuplicateFlag,
Error::InvalidChoice { .. } => Code::InvalidChoice,
Error::VarTooFew { .. } => Code::VarTooFew,
Error::VarTooMany { .. } => Code::VarTooMany,
Error::ConflictingFlags { .. } => Code::ConflictingFlags,
Error::InvalidValue(_) => Code::InvalidValue,
Error::MissingGroup { .. } => Code::MissingGroup,
Error::MissingSubcommand => Code::MissingSubcommand,
Error::Help { .. } => Code::Help,
Error::MissingArgsHelp { .. } => Code::MissingArgsHelp,
Error::HelpAll { .. } => Code::HelpAll,
Error::Version { .. } => Code::Version,
}
}
fn subject(error: &Error<'_, '_>) -> Option<String> {
match error {
Error::UnknownFlag { token } | Error::UnexpectedArg { token } => {
Some(String::from_utf8_lossy(token).into_owned())
}
Error::MissingFlagValue { flag } => Some(flag.name.to_string()),
Error::ArgRequiresDoubleDash { arg } => Some(arg.name.to_string()),
Error::SubcommandConflict { subcommand } => Some(subcommand.name.to_string()),
Error::MissingRequired { name }
| Error::DuplicateFlag { name }
| Error::InvalidChoice { name, .. }
| Error::VarTooFew { name, .. }
| Error::VarTooMany { name, .. }
| Error::ConflictingFlags { name, .. } => Some((*name).to_string()),
Error::InvalidValue(invalid) => Some(invalid.name.to_string()),
Error::MissingGroup { group, .. } => Some((*group).to_string()),
Error::TooDeep
| Error::MissingSubcommand
| Error::Help { .. }
| Error::MissingArgsHelp { .. }
| Error::HelpAll { .. }
| Error::Version { .. } => None,
}
}
fn location(
spec: &Spec<'_>,
argv: &[&std::ffi::OsStr],
error: &Error<'_, '_>,
view: Option<&ViewMeta<'_>>,
) -> Option<ArgvSpan> {
match error {
Error::UnknownFlag { token } | Error::UnexpectedArg { token } => span_of_slice(argv, token),
Error::MissingFlagValue { flag } => flag_span(spec.root.cmd, argv, flag, view),
Error::InvalidChoice { name, choices } => value_span(
spec.root.cmd,
argv,
name,
|value| !choices.contains(&value),
view,
),
Error::InvalidValue(invalid) => value_span(
spec.root.cmd,
argv,
invalid.name,
|value| value == invalid.value,
view,
),
_ => None,
}
}
pub fn report(spec: &Spec<'_>, argv: &[&std::ffi::OsStr], error: &Error<'_, '_>) -> Report {
Report {
code: code(error),
subject: subject(error),
location: location(spec, argv, error, None),
rendered: render(spec, argv, error, Style::PLAIN),
}
}
fn view_argv<'a>(
argv: &'a [&'a std::ffi::OsStr],
view: &'a ViewMeta<'_>,
) -> (Vec<&'a std::ffi::OsStr>, usize) {
let words = argv.get(1..).unwrap_or_default();
let root_depth = view.root.split_ascii_whitespace().count();
let mut rewritten = Vec::with_capacity(words.len() + root_depth);
rewritten.extend(view.root.split_ascii_whitespace().map(std::ffi::OsStr::new));
rewritten.extend_from_slice(words);
(rewritten, root_depth)
}
pub fn report_view<'a>(
spec: &'a Spec<'a>,
argv: &[&std::ffi::OsStr],
error: &Error<'_, '_>,
view: &'a ViewMeta<'a>,
) -> Report {
let (rewritten, root_depth) = view_argv(argv, view);
let location = location(spec, &rewritten, error, Some(view)).and_then(|span| {
(span.index >= root_depth).then_some(ArgvSpan {
index: span.index - root_depth + 1,
start: span.start,
end: span.end,
})
});
Report {
code: code(error),
subject: subject(error),
location,
rendered: render_inner(spec, &rewritten, error, Style::PLAIN, Some(view)),
}
}
pub fn render(
spec: &Spec<'_>,
argv: &[&std::ffi::OsStr],
error: &Error<'_, '_>,
style: Style,
) -> String {
render_inner(spec, argv, error, style, None)
}
pub fn render_warnings(warnings: &[crate::warn::Warning<'_>], style: Style) -> String {
let mut out = String::new();
for warning in warnings {
out.push_str(&style.warning("warning:"));
out.push(' ');
out.push_str(&style.invalid(&crate::warn::subject(warning)));
out.push_str(" is deprecated");
if let Some(at) = warning.remove_at {
out.push_str(", removed at ");
out.push_str(&style.literal(at));
}
match crate::warn::tail(warning) {
Some(crate::warn::Tail::Message(message)) => {
out.push_str(": ");
out.push_str(message);
}
Some(crate::warn::Tail::Replacement(replacement)) => {
out.push_str(": use ");
out.push_str(&style.valid(replacement));
}
None => {}
}
out.push('\n');
}
out
}
pub fn render_view<'a>(
spec: &'a Spec<'a>,
argv: &[&std::ffi::OsStr],
error: &Error<'_, '_>,
style: Style,
view: &'a ViewMeta<'a>,
) -> String {
let (rewritten, _) = view_argv(argv, view);
render_inner(spec, &rewritten, error, style, Some(view))
}
fn render_inner<'a>(
spec: &'a Spec<'a>,
argv: &[&std::ffi::OsStr],
error: &Error<'_, '_>,
style: Style,
view: Option<&'a ViewMeta<'a>>,
) -> String {
let canonical_spec = spec;
let taken = path_taken(spec.root.cmd, argv, view);
let cmd = *taken.last().expect("the root is always on the path");
let mut resolved = resolve(spec, &taken);
let mut projected_spec = None;
let mut projected_flags: Vec<FlagMeta<'_>> = Vec::new();
let mut projected_groups = Vec::new();
let projection = view.and_then(|view| {
let depth = view.root.split_ascii_whitespace().count();
resolved
.as_ref()
.and_then(|(_, chain)| chain.get(depth).copied())
.map(|promoted| (view, depth, promoted))
});
if let Some((_, _, promoted)) = projection {
let (flags, groups) =
crate::help::view_root_fields(spec, promoted, view.expect("a projection has a view"));
projected_flags = flags;
projected_groups = groups;
}
let projected_root = projection.map(|(_, _, promoted)| CommandMeta {
flags: &projected_flags,
groups: &projected_groups,
..*promoted
});
if let (Some((view, depth, promoted)), Some(root), Some((names, chain))) =
(projection, projected_root.as_ref(), resolved.as_mut())
{
chain[0] = root;
chain.drain(1..=depth.min(chain.len().saturating_sub(1)));
names.drain(1..=depth.min(names.len().saturating_sub(1)));
names[0] = view.bin;
projected_spec = Some(Spec {
name: view.name,
bin: Some(view.bin),
about: promoted.about,
long_about: promoted.long_about,
usage: None,
default_subcommand: None,
multicall: false,
root,
..*spec
});
}
let spec = projected_spec.as_ref().unwrap_or(spec);
let chain: &[&CommandMeta<'_>] = resolved.as_ref().map(|(_, c)| &c[..]).unwrap_or(&[]);
let here = chain.last().copied();
let path = resolved
.as_ref()
.map(|(names, _)| names.join(" "))
.unwrap_or_else(|| spec.bin.unwrap_or(spec.name).to_string());
let usage = match (&resolved, here) {
(Some((names, _)), Some(meta)) => crate::help::usage_line(names, meta),
_ => path.clone(),
};
let presented_route: Vec<&Command<'_>> = chain.iter().map(|meta| meta.cmd).collect();
let mut out = String::new();
let mut with_usage = false;
match error {
Error::UnknownFlag { token } => {
with_usage = true;
let whole = String::from_utf8_lossy(token);
let typed = flag_named(&whole);
let _ = writeln!(
out,
"{} unexpected argument '{}' found",
style.error("error:"),
style.invalid(typed)
);
let bare = typed.trim_start_matches('-');
let names: Vec<&str> = flags_in_scope(chain).flat_map(long_spellings).collect();
let near: Vec<String> = nearest(bare, names.into_iter())
.into_iter()
.map(|name| format!("--{name}"))
.collect();
out.push_str(&tip(
style,
"argument",
&near.iter().map(String::as_str).collect::<Vec<_>>(),
));
}
Error::UnexpectedArg { token } => {
with_usage = true;
let word = String::from_utf8_lossy(token);
if word.starts_with('-') && word != "-" {
let named = flag_named(&word);
let _ = writeln!(
out,
"{} unexpected argument '{}' found",
style.error("error:"),
style.invalid(named)
);
let bare = named.trim_start_matches('-');
let names: Vec<&str> = flags_in_scope(chain).flat_map(long_spellings).collect();
let near: Vec<String> = nearest(bare, names.into_iter())
.into_iter()
.map(|name| format!("--{name}"))
.collect();
out.push_str(&tip(
style,
"argument",
&near.iter().map(String::as_str).collect::<Vec<_>>(),
));
} else if cmd.subcommands.is_empty() {
let _ = writeln!(
out,
"{} unexpected argument '{}' found",
style.error("error:"),
style.invalid(&word)
);
} else {
let _ = writeln!(
out,
"{} unrecognized subcommand '{}'",
style.error("error:"),
style.invalid(&word)
);
let names: Vec<&str> = cmd
.subcommands
.iter()
.flat_map(|sub| core::iter::once(sub.name).chain(sub.aliases.iter().copied()))
.collect();
out.push_str(&tip(
style,
"subcommand",
&nearest(&word, names.into_iter()),
));
}
}
Error::SubcommandConflict { subcommand } => {
with_usage = true;
let _ = writeln!(
out,
"{} the subcommand '{}' cannot be used with arguments on its parent command",
style.error("error:"),
style.invalid(subcommand.name)
);
}
Error::MissingRequired { name } => {
with_usage = true;
let _ = writeln!(
out,
"{} the following required arguments were not provided:",
style.error("error:")
);
let _ = writeln!(out, " {}", style.valid(&shown(here, name)));
}
Error::DuplicateFlag { name } => {
with_usage = true;
let _ = writeln!(
out,
"{} the argument '{}' cannot be used multiple times",
style.error("error:"),
style.invalid(&shown(here, name))
);
}
Error::MissingSubcommand => {
let help_style = if style == Style::COLOURED {
crate::help::Style::COLOURED
} else {
crate::help::Style::PLAIN
};
let help = crate::help::render_at_styled(spec, &presented_route, false, help_style);
if let Some(help) = help {
return help;
}
with_usage = true;
let _ = writeln!(
out,
"{} '{}' requires a subcommand but one was not provided",
style.error("error:"),
style.invalid(&path)
);
}
Error::MissingFlagValue { flag } => {
let name = flag
.longs
.first()
.map(|l| format!("--{l}"))
.or_else(|| flag.shorts.first().map(|s| format!("-{}", *s as char)))
.unwrap_or_else(|| flag.name.to_string());
let value = here
.and_then(|meta| {
meta.flags
.iter()
.find(|m| core::ptr::eq(m.flag, *flag))
.and_then(|m| m.value_name)
})
.unwrap_or(flag.name);
if flag.require_equals {
let _ = writeln!(
out,
"{} equal sign is needed when assigning values to '{}'",
style.error("error:"),
style.invalid(&format!("{name}=<{value}>"))
);
} else {
let _ = writeln!(
out,
"{} a value is required for '{}' but none was supplied",
style.error("error:"),
style.invalid(&format!("{name} <{value}>"))
);
}
}
Error::InvalidChoice { name, choices } => {
let shown_name = shown(here, name);
let typed = value_bound_to(canonical_spec.root.cmd, argv, name, choices, view);
match typed.as_deref() {
Some(value) => {
let _ = writeln!(
out,
"{} invalid value '{}' for '{}'",
style.error("error:"),
style.invalid(value),
style.literal(&shown_name)
);
}
None => {
let _ = writeln!(
out,
"{} invalid value for '{}'",
style.error("error:"),
style.literal(&shown_name)
);
}
}
let listed: Vec<String> = choices.iter().map(|c| style.valid(c)).collect();
let _ = writeln!(out, " [possible values: {}]", listed.join(", "));
if let Some(typed) = typed {
out.push_str(&tip(
style,
"value",
&nearest(&typed, choices.iter().copied()),
));
}
}
Error::InvalidValue(invalid) => {
let _ = writeln!(
out,
"{} invalid value '{}' for '{}': {}",
style.error("error:"),
style.invalid(&invalid.value),
style.literal(&shown(here, invalid.name)),
invalid.reason
);
}
Error::MissingGroup { group, members } => {
with_usage = true;
let _ = writeln!(
out,
"{} one of the following required arguments was not provided ({group}):",
style.error("error:")
);
for member in *members {
let _ = writeln!(out, " {}", style.valid(&group_member_shown(here, member)));
}
}
Error::ConflictingFlags { name, other } => {
let _ = writeln!(
out,
"{} the argument '{}' cannot be used with '{}'",
style.error("error:"),
style.invalid(&shown(here, name)),
style.invalid(&shown(here, other))
);
with_usage = true;
}
Error::VarTooFew { name, min, got } => {
let _ = writeln!(
out,
"{} {min} values required for '{}' but {got} were provided",
style.error("error:"),
style.literal(&shown(here, name))
);
}
Error::VarTooMany { name, max, got } => {
let _ = writeln!(
out,
"{} {max} values allowed for '{}' but {got} were provided",
style.error("error:"),
style.literal(&shown(here, name))
);
}
Error::ArgRequiresDoubleDash { arg } => {
with_usage = true;
let _ = writeln!(
out,
"{} '{}' can only be given after '{}'",
style.error("error:"),
style.literal(&shown(here, arg.name)),
style.literal("--")
);
}
Error::TooDeep => {
let _ = writeln!(
out,
"{} this command line nests deeper than the parser goes",
style.error("error:")
);
}
Error::Help { .. }
| Error::MissingArgsHelp { .. }
| Error::HelpAll { .. }
| Error::Version { .. } => {
return String::new();
}
}
if with_usage {
let _ = writeln!(
out,
"\n{} {}",
style.heading("Usage:"),
style.literal(&usage)
);
}
let _ = writeln!(
out,
"\nFor more information, try '{}'.",
style.literal("--help")
);
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::spec::{ArgMeta, FlagMeta};
use crate::{Arg, Flag};
static FORCE: Flag = Flag {
key: 1,
name: "force",
longs: &["force"],
shorts: b"f",
negate: Some("no-force"),
..Flag::BOOL
};
static JOBS: Flag = Flag {
key: 2,
name: "jobs",
longs: &["jobs"],
..Flag::VALUE
};
static TOOL: Arg = Arg {
key: 3,
name: "TOOL",
..Arg::REQUIRED
};
static SHELLS: Arg = Arg {
key: 7,
name: "SHELLS",
..Arg::VAR
};
static USE: Command = Command {
name: "use",
flags: &[&FORCE, &JOBS],
args: &[&TOOL, &SHELLS],
..Command::EMPTY
};
static QUIET: Flag = Flag {
key: 4,
name: "quiet",
longs: &["quiet"],
global: true,
..Flag::BOOL
};
static LOCAL: Flag = Flag {
key: 5,
name: "local",
longs: &["local"],
global: true,
..Flag::BOOL
};
static USER: Command = Command {
name: "user",
flags: &[&LOCAL],
..Command::EMPTY
};
static SETUP: Flag = Flag {
key: 6,
name: "setup",
longs: &["setup"],
..Flag::BOOL
};
static ROOT: Command = Command {
name: "ex",
flags: &[&QUIET, &SETUP],
subcommands: &[&USER, &USE],
..Command::EMPTY
};
static USE_META: CommandMeta = CommandMeta {
cmd: &USE,
about: Some("Use a tool"),
flags: &[
FlagMeta {
flag: &FORCE,
help: Some("Force it"),
..FlagMeta::EMPTY
},
FlagMeta {
flag: &JOBS,
help: Some("How many"),
value_name: Some("JOBS"),
..FlagMeta::EMPTY
},
],
args: &[
ArgMeta {
arg: &TOOL,
help: Some("Which tool"),
required: true,
..ArgMeta::EMPTY
},
ArgMeta {
arg: &SHELLS,
help: Some("Which shells"),
choices: &["bash", "zsh"],
required: false,
..ArgMeta::EMPTY
},
],
..CommandMeta::EMPTY
};
static USER_META: CommandMeta = CommandMeta {
cmd: &USER,
about: Some("Manage users"),
flags: &[FlagMeta {
flag: &LOCAL,
help: Some("Only this checkout"),
..FlagMeta::EMPTY
}],
..CommandMeta::EMPTY
};
static ROOT_META: CommandMeta = CommandMeta {
cmd: &ROOT,
flags: &[
FlagMeta {
flag: &QUIET,
help: Some("Say less"),
..FlagMeta::EMPTY
},
FlagMeta {
flag: &SETUP,
help: Some("Set things up"),
..FlagMeta::EMPTY
},
],
subcommands: &[&USER_META, &USE_META],
..CommandMeta::EMPTY
};
static SPEC: Spec = Spec {
name: "ex",
bin: Some("ex"),
root: &ROOT_META,
..Spec::EMPTY
};
fn rendered(words: &[&str], error: Error<'static, 'static>) -> String {
let owned: Vec<std::ffi::OsString> = words.iter().map(std::ffi::OsString::from).collect();
let argv: Vec<&std::ffi::OsStr> = owned.iter().map(|o| o.as_os_str()).collect();
render(&SPEC, &argv, &error, Style::PLAIN)
}
#[test]
fn a_structured_report_locates_the_exact_unknown_word() {
let owned = [
std::ffi::OsString::from("use"),
std::ffi::OsString::from("--fore"),
];
let argv = owned
.iter()
.map(|word| word.as_os_str())
.collect::<Vec<_>>();
let error = Error::UnknownFlag {
token: argv[1].as_encoded_bytes(),
};
let report = report(&SPEC, &argv, &error);
assert_eq!(report.code, Code::UnknownFlag);
assert_eq!(report.code.as_str(), "unknown_flag");
assert_eq!(report.subject.as_deref(), Some("--fore"));
assert_eq!(
report.location,
Some(ArgvSpan {
index: 1,
start: 0,
end: 6,
})
);
assert!(report.rendered.starts_with("error: unexpected argument"));
}
#[test]
fn a_structured_report_locates_an_attached_invalid_value() {
let owned = [
std::ffi::OsString::from("use"),
std::ffi::OsString::from("--jobs=wat"),
std::ffi::OsString::from("tool"),
];
let argv = owned
.iter()
.map(|word| word.as_os_str())
.collect::<Vec<_>>();
let error = Error::InvalidValue(Box::new(crate::InvalidValue {
name: "jobs",
value: "wat".to_string(),
reason: "invalid digit found in string".to_string(),
}));
let report = report(&SPEC, &argv, &error);
assert_eq!(report.code, Code::InvalidValue);
assert_eq!(
report.location,
Some(ArgvSpan {
index: 1,
start: 7,
end: 10,
})
);
}
#[test]
fn a_structured_report_finds_the_refused_value_in_a_variadic() {
let owned = [
std::ffi::OsString::from("use"),
std::ffi::OsString::from("tool"),
std::ffi::OsString::from("bash"),
std::ffi::OsString::from("fish"),
];
let argv = owned
.iter()
.map(|word| word.as_os_str())
.collect::<Vec<_>>();
let error = Error::InvalidChoice {
name: "SHELLS",
choices: &["bash", "zsh"],
};
assert_eq!(
report(&SPEC, &argv, &error).location,
Some(ArgvSpan {
index: 3,
start: 0,
end: 4,
})
);
}
#[test]
fn a_missing_flag_value_points_at_the_flag() {
let owned = [
std::ffi::OsString::from("use"),
std::ffi::OsString::from("--jobs"),
];
let argv = owned
.iter()
.map(|word| word.as_os_str())
.collect::<Vec<_>>();
let error = Error::MissingFlagValue { flag: &JOBS };
assert_eq!(
report(&SPEC, &argv, &error).location,
Some(ArgvSpan {
index: 1,
start: 0,
end: 6,
})
);
}
#[test]
fn a_repeated_missing_flag_points_at_the_occurrence_the_parser_refused() {
let owned = [
std::ffi::OsString::from("use"),
std::ffi::OsString::from("--jobs"),
std::ffi::OsString::from("4"),
std::ffi::OsString::from("--jobs"),
];
let argv = owned
.iter()
.map(|word| word.as_os_str())
.collect::<Vec<_>>();
let error = Error::MissingFlagValue { flag: &JOBS };
assert_eq!(
report(&SPEC, &argv, &error).location,
Some(ArgvSpan {
index: 3,
start: 0,
end: 6,
})
);
}
#[test]
fn view_reports_map_invalid_values_back_to_the_original_argv() {
static VIEW: ViewMeta = ViewMeta {
id: "runner",
name: "runner",
bin: "runner",
root: "use",
all_globals: false,
globals: &[],
};
let invalid_owned = [
std::ffi::OsString::from("runner"),
std::ffi::OsString::from("--jobs=wat"),
];
let invalid_argv = invalid_owned
.iter()
.map(|word| word.as_os_str())
.collect::<Vec<_>>();
let invalid = Error::InvalidValue(Box::new(crate::InvalidValue {
name: "jobs",
value: "wat".to_string(),
reason: "invalid digit found in string".to_string(),
}));
assert_eq!(
report_view(&SPEC, &invalid_argv, &invalid, &VIEW).location,
Some(ArgvSpan {
index: 1,
start: 7,
end: 10,
})
);
let choice_owned = [
std::ffi::OsString::from("runner"),
std::ffi::OsString::from("tool"),
std::ffi::OsString::from("fish"),
];
let choice_argv = choice_owned
.iter()
.map(|word| word.as_os_str())
.collect::<Vec<_>>();
let choice = Error::InvalidChoice {
name: "SHELLS",
choices: &["bash", "zsh"],
};
assert_eq!(
report_view(&SPEC, &choice_argv, &choice, &VIEW).location,
Some(ArgvSpan {
index: 2,
start: 0,
end: 4,
})
);
}
#[test]
fn the_usage_line_is_the_one_the_help_prints() {
let message = rendered(&["use"], Error::UnknownFlag { token: b"--fore" });
let line = message
.lines()
.find_map(|l| l.strip_prefix("Usage: "))
.expect("a usage line");
assert_eq!(line, crate::help::usage_line(&["ex", "use"], &USE_META));
}
#[test]
fn a_missing_group_lists_value_taking_members_completely() {
static FILE: Flag = Flag {
name: "file",
longs: &["file"],
takes_value: true,
..Flag::BOOL
};
static ROOT: Command = Command {
name: "grouped",
flags: &[&FILE],
..Command::EMPTY
};
static META: CommandMeta = CommandMeta {
cmd: &ROOT,
flags: &[FlagMeta {
flag: &FILE,
value_name: Some("PATH"),
..FlagMeta::EMPTY
}],
..CommandMeta::EMPTY
};
static SPEC: Spec = Spec {
name: "grouped",
bin: Some("grouped"),
root: &META,
..Spec::EMPTY
};
let message = render(
&SPEC,
&[],
&Error::MissingGroup {
group: "input",
members: &["--file"],
},
Style::PLAIN,
);
assert!(message.contains(" --file <PATH>"), "{message}");
}
#[test]
fn a_missing_subcommand_prints_the_choices() {
let message = rendered(&[], Error::MissingSubcommand);
assert!(message.contains("Commands:"), "{message}");
assert!(message.contains("use"), "{message}");
assert!(message.contains("user"), "{message}");
assert!(!message.contains("requires a subcommand"), "{message}");
let coloured = render(&SPEC, &[], &Error::MissingSubcommand, Style::COLOURED);
assert!(
coloured.contains("\u{1b}[1;33mCommands:\u{1b}[0m"),
"{coloured}"
);
}
#[test]
fn a_view_missing_subcommand_prints_the_promoted_choices() {
static CHILD: Command = Command {
name: "child",
..Command::EMPTY
};
static RUN: Command = Command {
name: "run",
subcommands: &[&CHILD],
..Command::EMPTY
};
static HOST: Command = Command {
name: "host",
subcommands: &[&RUN],
..Command::EMPTY
};
static CHILD_META: CommandMeta = CommandMeta {
cmd: &CHILD,
about: Some("Do the child thing"),
..CommandMeta::EMPTY
};
static RUN_META: CommandMeta = CommandMeta {
cmd: &RUN,
subcommands: &[&CHILD_META],
..CommandMeta::EMPTY
};
static HOST_META: CommandMeta = CommandMeta {
cmd: &HOST,
subcommands: &[&RUN_META],
..CommandMeta::EMPTY
};
static VIEW: ViewMeta = ViewMeta {
id: "runner",
name: "runner",
bin: "runner",
root: "run",
all_globals: false,
globals: &[],
};
static VIEW_SPEC: Spec = Spec {
name: "host",
bin: Some("host"),
root: &HOST_META,
views: &[VIEW],
..Spec::EMPTY
};
let message = render_view(
&VIEW_SPEC,
&[std::ffi::OsStr::new("runner")],
&Error::MissingSubcommand,
Style::PLAIN,
&VIEW,
);
assert!(message.contains("Usage: runner"), "{message}");
assert!(message.contains("Commands:"), "{message}");
assert!(message.contains("child"), "{message}");
assert!(!message.contains("requires a subcommand"), "{message}");
}
#[test]
fn a_missing_value_names_what_it_wanted() {
assert_eq!(
rendered(&["use"], Error::MissingFlagValue { flag: &JOBS }),
"error: a value is required for '--jobs <JOBS>' but none was supplied\n\
\n\
For more information, try '--help'.\n"
);
}
#[test]
fn a_word_where_a_subcommand_was_expected_says_so() {
let at_root = rendered(&[], Error::UnexpectedArg { token: b"nonesuch" });
assert!(
at_root.starts_with("error: unrecognized subcommand 'nonesuch'"),
"{at_root}"
);
let in_use = rendered(&["use"], Error::UnexpectedArg { token: b"extra" });
assert!(
in_use.starts_with("error: unexpected argument 'extra' found"),
"{in_use}"
);
}
#[test]
fn a_required_argument_is_listed_the_way_clap_lists_it() {
assert_eq!(
rendered(&["use"], Error::MissingRequired { name: "<TOOL>" }),
"error: the following required arguments were not provided:\n \
<TOOL>\n\
\n\
Usage: ex use [-f --force] [--jobs <JOBS>] <TOOL> [SHELLS]…\n\
\n\
For more information, try '--help'.\n"
);
}
#[test]
fn colour_is_the_same_codes_clap_uses() {
let owned = [std::ffi::OsString::from("use")];
let argv: Vec<&std::ffi::OsStr> = owned.iter().map(|o| o.as_os_str()).collect();
let message = render(
&SPEC,
&argv,
&Error::UnknownFlag { token: b"--fore" },
Style::COLOURED,
);
assert!(
message.starts_with("\u{1b}[1m\u{1b}[31merror:\u{1b}[0m"),
"{message:?}"
);
assert!(message.contains("\u{1b}[33m--fore\u{1b}[0m"), "{message:?}");
assert!(
message.contains("\u{1b}[1m\u{1b}[4mUsage:\u{1b}[0m"),
"{message:?}"
);
assert!(!rendered(&["use"], Error::UnknownFlag { token: b"--fore" }).contains('\u{1b}'));
}
#[test]
fn a_help_request_renders_nothing() {
assert_eq!(
rendered(
&["use"],
Error::Help {
cmd: &USE,
long: true
}
),
""
);
}
#[test]
fn a_name_is_spelled_the_way_the_help_spells_it() {
let message = rendered(&["use"], Error::MissingRequired { name: "TOOL" });
assert!(message.contains(" <TOOL>"), "{message}");
let message = rendered(
&["use"],
Error::VarTooFew {
name: "SHELLS",
min: 2,
got: 1,
},
);
assert!(message.contains("'[SHELLS]…'"), "{message}");
let message = rendered(&["use"], Error::MissingRequired { name: "jobs" });
assert!(message.contains(" --jobs"), "{message}");
let message = rendered(&["use"], Error::DuplicateFlag { name: "jobs" });
assert!(
message.contains("the argument '--jobs' cannot be used multiple times"),
"{message}"
);
let message = rendered(
&["use"],
Error::ConflictingFlags {
name: "force",
other: "jobs",
},
);
assert!(
message.contains("the argument '--force' cannot be used with '--jobs'"),
"{message}"
);
let message = rendered(&["use"], Error::ArgRequiresDoubleDash { arg: &SHELLS });
assert!(
message.contains("'[SHELLS]…' can only be given"),
"{message}"
);
}
#[test]
fn the_value_named_is_the_one_that_was_refused() {
let owned = [
std::ffi::OsString::from("use"),
std::ffi::OsString::from("node"),
std::ffi::OsString::from("fsh"),
std::ffi::OsString::from("zsh"),
];
let argv: Vec<&std::ffi::OsStr> = owned.iter().map(|o| o.as_os_str()).collect();
let message = render(
&SPEC,
&argv,
&Error::InvalidChoice {
name: "SHELLS",
choices: &["bash", "zsh"],
},
Style::PLAIN,
);
assert!(
message.starts_with("error: invalid value 'fsh' for '[SHELLS]…'"),
"named a value that was fine: {message}"
);
}
#[test]
fn a_view_recovers_the_choice_value_from_the_canonical_parse() {
static VIEW: ViewMeta = ViewMeta {
id: "runner",
name: "runner",
bin: "runner",
root: "use",
all_globals: false,
globals: &[],
};
static VIEW_SPEC: Spec = Spec {
views: &[VIEW],
..SPEC
};
let argv = [std::ffi::OsStr::new("runner"), std::ffi::OsStr::new("nod")];
let message = render_view(
&VIEW_SPEC,
&argv,
&Error::InvalidChoice {
name: "TOOL",
choices: &["node", "python"],
},
Style::PLAIN,
&VIEW,
);
assert!(
message.contains("invalid value 'nod' for '<TOOL>'"),
"{message}"
);
assert!(
message.contains("tip: a similar value exists: 'node'"),
"{message}"
);
}
#[test]
fn a_near_miss_is_suggested_the_way_clap_suggests_one() {
let message = rendered(&["use"], Error::UnknownFlag { token: b"--fore" });
assert_eq!(
message,
"error: unexpected argument '--fore' found\n\
\n\
\x20 tip: a similar argument exists: '--force'\n\
\n\
Usage: ex use [-f --force] [--jobs <JOBS>] <TOOL> [SHELLS]…\n\
\n\
For more information, try '--help'.\n"
);
}
#[test]
fn a_value_attached_to_a_flag_is_not_part_of_its_name() {
assert_eq!(
rendered(&["use"], Error::UnknownFlag { token: b"--fore=1" }),
"error: unexpected argument '--fore' found\n\
\n\
\x20 tip: a similar argument exists: '--force'\n\
\n\
Usage: ex use [-f --force] [--jobs <JOBS>] <TOOL> [SHELLS]…\n\
\n\
For more information, try '--help'.\n"
);
let message = rendered(&["use"], Error::UnknownFlag { token: b"-j=4" });
assert!(
message.starts_with("error: unexpected argument '-j=4' found"),
"{message}"
);
}
#[test]
fn a_negation_is_suggested_like_any_other_spelling() {
let message = rendered(
&["use"],
Error::UnknownFlag {
token: b"--no-forc",
},
);
assert!(
message.contains("tip: a similar argument exists: '--no-force'"),
"{message}"
);
let message = rendered(&["use"], Error::UnknownFlag { token: b"--fore" });
assert!(
message.contains("tip: a similar argument exists: '--force'"),
"{message}"
);
}
#[test]
fn nothing_is_suggested_when_nothing_is_close() {
assert_eq!(
rendered(&["use"], Error::UnknownFlag { token: b"--zzz" }),
"error: unexpected argument '--zzz' found\n\
\n\
Usage: ex use [-f --force] [--jobs <JOBS>] <TOOL> [SHELLS]…\n\
\n\
For more information, try '--help'.\n"
);
}
#[test]
fn the_scores_are_the_ones_clap_would_compute() {
assert!(
(jaro("fore", "force") - 0.933).abs() < 0.001,
"{}",
jaro("fore", "force")
);
assert_eq!(jaro("same", "same"), 1.0);
assert_eq!(jaro("", ""), 1.0);
assert_eq!(jaro("abc", ""), 0.0);
assert_eq!(jaro("abc", "xyz"), 0.0);
assert_eq!(jaro("forc", "force"), jaro("orce", "force"));
}
#[test]
fn a_subcommand_and_a_value_get_the_same_treatment() {
let message = rendered(&[], Error::UnexpectedArg { token: b"usse" });
assert!(
message.contains("tip: some similar subcommands exist: 'user', 'use'"),
"{message}"
);
let owned = [
std::ffi::OsString::from("use"),
std::ffi::OsString::from("nod"),
];
let argv: Vec<&std::ffi::OsStr> = owned.iter().map(|o| o.as_os_str()).collect();
let message = render(
&SPEC,
&argv,
&Error::InvalidChoice {
name: "TOOL",
choices: &["node", "python"],
},
Style::PLAIN,
);
assert!(
message.contains("invalid value 'nod' for '<TOOL>'"),
"{message}"
);
assert!(
message.contains("tip: a similar value exists: 'node'"),
"{message}"
);
}
#[test]
fn a_global_flag_is_suggested_inside_a_subcommand() {
let message = rendered(&["use"], Error::UnknownFlag { token: b"--quie" });
assert!(
message.contains("tip: a similar argument exists: '--quiet'"),
"{message}"
);
}
#[test]
fn a_dash_prefixed_word_is_a_flag_even_where_subcommands_exist() {
let message = rendered(&[], Error::UnexpectedArg { token: b"--quie" });
assert!(
message.starts_with("error: unexpected argument '--quie' found"),
"{message}"
);
assert!(
message.contains("tip: a similar argument exists: '--quiet'"),
"{message}"
);
assert!(!message.contains("subcommand"), "{message}");
let message = rendered(&[], Error::UnexpectedArg { token: b"usse" });
assert!(
message.starts_with("error: unrecognized subcommand 'usse'"),
"{message}"
);
let message = rendered(&[], Error::UnexpectedArg { token: b"-" });
assert!(
message.starts_with("error: unrecognized subcommand '-'"),
"{message}"
);
}
#[test]
fn a_siblings_global_is_not_offered_here() {
let message = rendered(&["use"], Error::UnknownFlag { token: b"--locl" });
assert!(!message.contains("tip:"), "{message}");
let message = rendered(&["user"], Error::UnknownFlag { token: b"--locl" });
assert!(
message.contains("tip: a similar argument exists: '--local'"),
"{message}"
);
let message = rendered(&["use"], Error::UnknownFlag { token: b"--quie" });
assert!(
message.contains("tip: a similar argument exists: '--quiet'"),
"{message}"
);
let message = rendered(&["use"], Error::UnknownFlag { token: b"--setu" });
assert!(!message.contains("tip:"), "{message}");
let message = rendered(&[], Error::UnknownFlag { token: b"--setu" });
assert!(
message.contains("tip: a similar argument exists: '--setup'"),
"{message}"
);
}
}