use kdl::{KdlDocument, KdlEntry, KdlNode};
use serde::Serialize;
use std::fmt::Display;
use std::hash::Hash;
use std::str::FromStr;
use crate::error::UsageErr;
use crate::spec::builder::SpecArgBuilder;
use crate::spec::context::ParsingContext;
use crate::spec::effect::{SpecCommandEffect, EFFECT_VALUES};
use crate::spec::helpers::{string_entry, NodeHelper};
use crate::spec::is_false;
use crate::{string, SpecChoices};
#[cfg(feature = "clap")]
use crate::{SpecChoice, SpecChoiceAlias};
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct SpecRequiredIfEq {
pub selector: String,
pub value: String,
}
#[derive(Debug, Default, Clone, Serialize, PartialEq, Eq, strum::EnumString, strum::Display)]
#[strum(serialize_all = "snake_case")]
pub enum SpecDoubleDashChoices {
Automatic,
#[default]
Optional,
Required,
Preserve,
}
#[derive(Debug, Default, Clone, Serialize)]
#[non_exhaustive]
pub struct SpecArg {
pub name: String,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub value_names: Vec<String>,
pub usage: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub help: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub help_long: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub help_md: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub help_first_line: Option<String>,
pub required: bool,
pub double_dash: SpecDoubleDashChoices,
#[serde(skip_serializing_if = "is_false")]
pub var: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub var_min: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub var_max: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub delimiter: Option<char>,
#[serde(skip_serializing_if = "is_false")]
pub allow_negative_numbers: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub value_terminator: Option<String>,
pub hide: bool,
#[serde(skip_serializing_if = "is_false")]
pub hide_default_value: bool,
#[serde(skip_serializing_if = "is_false")]
pub hide_env: bool,
#[serde(skip_serializing_if = "is_false")]
pub hide_env_values: bool,
#[serde(skip_serializing_if = "is_false")]
pub hide_possible_values: bool,
#[serde(skip_serializing_if = "is_false")]
pub hide_short_help: bool,
#[serde(skip_serializing_if = "is_false")]
pub hide_long_help: bool,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub conflicts: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub requires: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub required_if: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub required_if_eq: Vec<SpecRequiredIfEq>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub required_if_eq_all: Vec<SpecRequiredIfEq>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub required_unless: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub required_unless_all: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub default: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub choices: Option<SpecChoices>,
#[serde(skip_serializing_if = "Option::is_none")]
pub validate: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub validate_error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub effect: Option<SpecCommandEffect>,
#[serde(skip_serializing_if = "Option::is_none")]
pub env: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub env_fallback: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub deprecated_env: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub help_heading: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub display_order: Option<usize>,
}
impl SpecArg {
pub fn builder() -> SpecArgBuilder {
SpecArgBuilder::new()
}
pub fn env_names(&self) -> impl Iterator<Item = &str> {
self.env
.iter()
.map(String::as_str)
.chain(self.env_fallback.iter().map(String::as_str))
.chain(self.deprecated_env.iter().map(String::as_str))
}
pub(crate) fn parse(ctx: &ParsingContext, node: &NodeHelper) -> Result<Self, UsageErr> {
let mut arg: SpecArg = node.arg(0)?.ensure_string()?.parse()?;
for (k, v) in node.props() {
match k {
"help" => arg.help = Some(v.ensure_string()?),
"long_help" => arg.help_long = Some(v.ensure_string()?),
"help_long" => arg.help_long = Some(v.ensure_string()?),
"help_md" => arg.help_md = Some(v.ensure_string()?),
"required" => arg.required = v.ensure_bool()?,
"double_dash" => arg.double_dash = v.ensure_string()?.parse()?,
"var" => arg.var = v.ensure_bool()?,
"delimiter" => {
let raw = v.ensure_string()?;
let mut chars = raw.chars();
match (chars.next(), chars.next()) {
(Some(c), None) if c.is_ascii() => arg.delimiter = Some(c),
(Some(c), None) => bail_parse!(
ctx,
v.entry.span(),
"a delimiter is one byte, and {c:?} is more than one; use an \
ASCII separator"
),
_ => bail_parse!(
ctx,
v.entry.span(),
"a delimiter is one character, and {raw:?} is not"
),
}
}
"allow_negative_numbers" => arg.allow_negative_numbers = v.ensure_bool()?,
"value_terminator" => arg.value_terminator = v.ensure_string().map(Some)?,
"hide" => arg.hide = v.ensure_bool()?,
"hide_default_value" => arg.hide_default_value = v.ensure_bool()?,
"hide_env" => arg.hide_env = v.ensure_bool()?,
"hide_env_values" => arg.hide_env_values = v.ensure_bool()?,
"hide_possible_values" => arg.hide_possible_values = v.ensure_bool()?,
"hide_short_help" => arg.hide_short_help = v.ensure_bool()?,
"hide_long_help" => arg.hide_long_help = v.ensure_bool()?,
"conflicts" => arg.conflicts = vec![v.ensure_string()?],
"requires" => arg.requires = vec![v.ensure_string()?],
"required_if" => arg.required_if = vec![v.ensure_string()?],
"required_unless" => arg.required_unless = vec![v.ensure_string()?],
"required_unless_all" => arg.required_unless_all = vec![v.ensure_string()?],
"var_min" => arg.var_min = v.ensure_usize().map(Some)?,
"var_max" => arg.var_max = v.ensure_usize().map(Some)?,
"default" => arg.default = vec![v.ensure_string()?],
"effect" => {
let raw = v.ensure_string()?;
match raw.parse() {
Ok(effect) => arg.effect = Some(effect),
Err(_) => bail_parse!(
ctx,
v.entry.span(),
"unsupported effect {raw}, expected one of: {EFFECT_VALUES}"
),
}
}
"env" => arg.env = v.ensure_string().map(Some)?,
"env_fallback" => arg.env_fallback = vec![v.ensure_string()?],
"deprecated_env" => arg.deprecated_env = vec![v.ensure_string()?],
"validate" => arg.validate = v.ensure_string().map(Some)?,
"validate_error" => arg.validate_error = v.ensure_string().map(Some)?,
"help_heading" => arg.help_heading = v.ensure_string().map(Some)?,
"display_order" => arg.display_order = v.ensure_usize().map(Some)?,
k => bail_parse!(ctx, v.entry.span(), "unsupported arg key {k}"),
}
}
if !arg.default.is_empty() {
arg.required = false;
}
for child in node.children() {
match child.name() {
"choices" => arg.choices = Some(SpecChoices::parse(ctx, &child)?),
"effect" => {
let a = child.arg(0)?;
let raw = a.ensure_string()?;
match raw.parse() {
Ok(effect) => arg.effect = Some(effect),
Err(_) => bail_parse!(
ctx,
a.entry.span(),
"unsupported effect {raw}, expected one of: {EFFECT_VALUES}"
),
}
}
"env" => arg.env = child.arg(0)?.ensure_string().map(Some)?,
"env_fallback" => arg.env_fallback = string_args(&child)?,
"deprecated_env" => arg.deprecated_env = string_args(&child)?,
"validate" => arg.validate = child.arg(0)?.ensure_string().map(Some)?,
"validate_error" => {
arg.validate_error = child.arg(0)?.ensure_string().map(Some)?;
}
"help_heading" => {
arg.help_heading = child.arg(0)?.ensure_string().map(Some)?;
}
"display_order" => {
arg.display_order = child.arg(0)?.ensure_usize().map(Some)?;
}
"default" => {
let children = child.children();
if children.is_empty() {
arg.default = vec![child.arg(0)?.ensure_string()?];
} else {
arg.default = children.iter().map(|c| c.name().to_string()).collect();
}
}
"help" => arg.help = Some(child.arg(0)?.ensure_string()?),
"long_help" => arg.help_long = Some(child.arg(0)?.ensure_string()?),
"help_long" => arg.help_long = Some(child.arg(0)?.ensure_string()?),
"help_md" => arg.help_md = Some(child.arg(0)?.ensure_string()?),
"required" => arg.required = child.arg(0)?.ensure_bool()?,
"var" => arg.var = child.arg(0)?.ensure_bool()?,
"var_min" => arg.var_min = child.arg(0)?.ensure_usize().map(Some)?,
"var_max" => arg.var_max = child.arg(0)?.ensure_usize().map(Some)?,
"value_names" => {
arg.value_names = child
.ensure_arg_len(1..)?
.args()
.map(|entry| entry.ensure_string())
.collect::<Result<Vec<_>, _>>()?;
}
"allow_negative_numbers" => {
arg.allow_negative_numbers = child.arg(0)?.ensure_bool()?;
}
"value_terminator" => {
arg.value_terminator = child.arg(0)?.ensure_string().map(Some)?;
}
"hide" => arg.hide = child.arg(0)?.ensure_bool()?,
"hide_default_value" => arg.hide_default_value = child.arg(0)?.ensure_bool()?,
"hide_env" => arg.hide_env = child.arg(0)?.ensure_bool()?,
"hide_env_values" => arg.hide_env_values = child.arg(0)?.ensure_bool()?,
"hide_possible_values" => arg.hide_possible_values = child.arg(0)?.ensure_bool()?,
"hide_short_help" => arg.hide_short_help = child.arg(0)?.ensure_bool()?,
"hide_long_help" => arg.hide_long_help = child.arg(0)?.ensure_bool()?,
"conflicts" => {
arg.conflicts = child
.ensure_arg_len(1..)?
.args()
.map(|entry| entry.ensure_string())
.collect::<Result<Vec<_>, _>>()?;
}
"requires" => arg.requires = string_args(&child)?,
"required_if" => arg.required_if = string_args(&child)?,
"required_if_eq" => arg.required_if_eq.push(required_if_eq(&child)?),
"required_if_eq_all" => {
let len = child.args().count();
if len < 2 || len % 2 != 0 {
bail_parse!(
ctx,
child.node.name().span(),
"required_if_eq_all needs selector/value pairs"
);
}
arg.required_if_eq_all = required_if_eq_pairs(&child)?;
}
"required_unless" => arg.required_unless = string_args(&child)?,
"required_unless_all" => arg.required_unless_all = string_args(&child)?,
"double_dash" => arg.double_dash = child.arg(0)?.ensure_string()?.parse()?,
k => bail_parse!(ctx, child.node.name().span(), "unsupported arg child {k}"),
}
}
if let Some(first) = arg.value_names.first() {
arg.name.clone_from(first);
}
if arg.value_names.len() > 1 {
let arity = arg.value_names.len();
match (arg.var_min, arg.var_max) {
(None, None) => {
arg.var_min = Some(arity);
arg.var_max = Some(arity);
}
(Some(min), Some(max)) if min == arity && max == arity => {}
_ => bail_parse!(
ctx,
node.node.name().span(),
"{arity} value names require var_min={arity} and var_max={arity}"
),
}
arg.var = true;
}
if arg.validate_error.is_some() && arg.validate.is_none() {
bail_parse!(
ctx,
node.node.name().span(),
"validate_error requires a validate expression"
);
}
if arg.value_terminator.as_deref() == Some("") {
bail_parse!(
ctx,
node.node.name().span(),
"value_terminator cannot be empty"
);
}
if arg.value_terminator.is_some() && !arg.var {
bail_parse!(
ctx,
node.node.name().span(),
"value_terminator requires a variadic argument"
);
}
#[cfg(feature = "validation")]
if let Some(expression) = &arg.validate {
if let Err(error) = usage_validation::check(expression) {
bail_parse!(
ctx,
node.node.name().span(),
"invalid validation expression: {error}"
);
}
}
arg.usage = arg.usage();
if let Some(help) = &arg.help {
arg.help_first_line = Some(string::first_line(help));
}
Ok(arg)
}
}
impl SpecArg {
pub fn usage(&self) -> String {
let exact_arity = self.var.then_some(()).and_then(|()| {
self.var_min
.zip(self.var_max)
.filter(|(min, max)| min == max && *min > 1)
.map(|(arity, _)| arity)
});
if self.value_names.len() > 1 || exact_arity.is_some() {
let labels = if self.value_names.len() > 1 {
self.value_names.clone()
} else {
vec![
self.value_names
.first()
.cloned()
.unwrap_or_else(|| self.name.clone());
exact_arity.expect("branch checked")
]
};
let placeholders = labels
.iter()
.map(|name| {
if self.required {
format!("<{name}>")
} else {
format!("[{name}]")
}
})
.collect::<Vec<_>>()
.join(" ");
return if self.double_dash == SpecDoubleDashChoices::Required {
format!("-- {placeholders}")
} else {
placeholders
};
}
let name = if self.double_dash == SpecDoubleDashChoices::Required {
format!("-- {}", self.name)
} else {
self.name.clone()
};
let mut name = if self.required {
format!("<{name}>")
} else {
format!("[{name}]")
};
if self.var {
name = format!("{name}…");
}
name
}
}
impl From<&SpecArg> for KdlNode {
fn from(arg: &SpecArg) -> Self {
let mut node = KdlNode::new("arg");
node.push(KdlEntry::new(arg.usage()));
if let Some(desc) = &arg.help {
node.push(string_entry(Some("help"), desc));
}
if let Some(desc) = &arg.help_long {
node.push(string_entry(Some("help_long"), desc));
}
if let Some(desc) = &arg.help_md {
node.push(string_entry(Some("help_md"), desc));
}
if !arg.required {
node.push(KdlEntry::new_prop("required", false));
}
if arg.double_dash == SpecDoubleDashChoices::Automatic
|| arg.double_dash == SpecDoubleDashChoices::Preserve
{
node.push(KdlEntry::new_prop(
"double_dash",
arg.double_dash.to_string(),
));
}
if arg.var {
node.push(KdlEntry::new_prop("var", true));
}
if let Some(min) = arg.var_min {
node.push(KdlEntry::new_prop("var_min", min as i128));
}
if let Some(max) = arg.var_max {
node.push(KdlEntry::new_prop("var_max", max as i128));
}
if let Some(delimiter) = arg.delimiter {
node.push(string_entry(Some("delimiter"), &delimiter.to_string()));
}
if arg.allow_negative_numbers {
node.push(KdlEntry::new_prop("allow_negative_numbers", true));
}
if let Some(terminator) = &arg.value_terminator {
node.push(string_entry(Some("value_terminator"), terminator));
}
if arg.hide {
node.push(KdlEntry::new_prop("hide", true));
}
for (name, hidden) in [
("hide_default_value", arg.hide_default_value),
("hide_env", arg.hide_env),
("hide_env_values", arg.hide_env_values),
("hide_possible_values", arg.hide_possible_values),
("hide_short_help", arg.hide_short_help),
("hide_long_help", arg.hide_long_help),
] {
if hidden {
node.push(KdlEntry::new_prop(name, true));
}
}
if arg.conflicts.len() == 1 {
node.push(string_entry(Some("conflicts"), &arg.conflicts[0]));
} else if !arg.conflicts.is_empty() {
let children = node.children_mut().get_or_insert_with(KdlDocument::new);
let mut conflicts = KdlNode::new("conflicts");
for target in &arg.conflicts {
conflicts.push(string_entry(None, target));
}
children.nodes_mut().push(conflicts);
}
serialize_selector_list(&mut node, "requires", &arg.requires);
serialize_selector_list(&mut node, "required_if", &arg.required_if);
serialize_required_if_eq(&mut node, "required_if_eq", &arg.required_if_eq);
if !arg.required_if_eq_all.is_empty() {
serialize_required_if_eq(&mut node, "required_if_eq_all", &arg.required_if_eq_all);
}
serialize_selector_list(&mut node, "required_unless", &arg.required_unless);
serialize_selector_list(&mut node, "required_unless_all", &arg.required_unless_all);
if !arg.default.is_empty() {
if arg.default.len() == 1 {
node.push(string_entry(Some("default"), &arg.default[0]));
} else {
let children = node.children_mut().get_or_insert_with(KdlDocument::new);
let mut default_node = KdlNode::new("default");
let default_children = default_node
.children_mut()
.get_or_insert_with(KdlDocument::new);
for val in &arg.default {
default_children
.nodes_mut()
.push(KdlNode::new(val.as_str()));
}
children.nodes_mut().push(default_node);
}
}
if let Some(env) = &arg.env {
node.push(string_entry(Some("env"), env));
}
serialize_selector_list(&mut node, "env_fallback", &arg.env_fallback);
serialize_selector_list(&mut node, "deprecated_env", &arg.deprecated_env);
if let Some(validate) = &arg.validate {
node.push(string_entry(Some("validate"), validate));
}
if arg.validate.is_some() {
if let Some(error) = &arg.validate_error {
node.push(string_entry(Some("validate_error"), error));
}
}
if let Some(help_heading) = &arg.help_heading {
node.push(string_entry(Some("help_heading"), help_heading));
}
if let Some(order) = arg.display_order {
node.push(KdlEntry::new_prop("display_order", order as i128));
}
if let Some(effect) = &arg.effect {
node.push(string_entry(Some("effect"), effect.as_str()));
}
if let Some(choices) = &arg.choices {
let children = node.children_mut().get_or_insert_with(KdlDocument::new);
children.nodes_mut().push(choices.into());
}
node
}
}
fn string_args(node: &NodeHelper<'_>) -> Result<Vec<String>, UsageErr> {
node.ensure_arg_len(1..)?
.args()
.map(|entry| entry.ensure_string())
.collect()
}
fn required_if_eq(node: &NodeHelper<'_>) -> Result<SpecRequiredIfEq, UsageErr> {
node.ensure_arg_len(2..=2)?;
Ok(SpecRequiredIfEq {
selector: node.arg(0)?.ensure_string()?,
value: node.arg(1)?.ensure_string()?,
})
}
fn required_if_eq_pairs(node: &NodeHelper<'_>) -> Result<Vec<SpecRequiredIfEq>, UsageErr> {
let entries = node.args().collect::<Vec<_>>();
entries
.chunks_exact(2)
.map(|pair| {
Ok(SpecRequiredIfEq {
selector: pair[0].ensure_string()?,
value: pair[1].ensure_string()?,
})
})
.collect()
}
fn serialize_selector_list(node: &mut KdlNode, name: &str, selectors: &[String]) {
if selectors.len() == 1 {
node.push(string_entry(Some(name), &selectors[0]));
} else if !selectors.is_empty() {
let children = node.children_mut().get_or_insert_with(KdlDocument::new);
let mut relation = KdlNode::new(name);
for selector in selectors {
relation.push(string_entry(None, selector));
}
children.nodes_mut().push(relation);
}
}
fn serialize_required_if_eq(node: &mut KdlNode, name: &str, conditions: &[SpecRequiredIfEq]) {
if conditions.is_empty() {
return;
}
let children = node.children_mut().get_or_insert_with(KdlDocument::new);
if name == "required_if_eq_all" {
let mut relation = KdlNode::new(name);
for condition in conditions {
relation.push(string_entry(None, &condition.selector));
relation.push(string_entry(None, &condition.value));
}
children.nodes_mut().push(relation);
} else {
for condition in conditions {
let mut relation = KdlNode::new(name);
relation.push(string_entry(None, &condition.selector));
relation.push(string_entry(None, &condition.value));
children.nodes_mut().push(relation);
}
}
}
impl From<&str> for SpecArg {
fn from(input: &str) -> Self {
let (input, after_double_dash) = input
.strip_prefix("-- ")
.map_or((input, false), |rest| (rest, true));
if let Some(placeholders) = fixed_placeholders(input) {
let required = placeholders
.iter()
.all(|placeholder| placeholder.starts_with('<'));
let value_names = placeholders
.iter()
.map(|placeholder| placeholder[1..placeholder.len() - 1].to_string())
.collect::<Vec<_>>();
return SpecArg {
name: value_names[0].clone(),
value_names,
required,
var: true,
var_min: Some(placeholders.len()),
var_max: Some(placeholders.len()),
double_dash: if after_double_dash {
SpecDoubleDashChoices::Required
} else {
SpecDoubleDashChoices::Optional
},
..Default::default()
};
}
let mut arg = SpecArg {
name: input.to_string(),
required: true,
double_dash: if after_double_dash {
SpecDoubleDashChoices::Required
} else {
SpecDoubleDashChoices::Optional
},
..Default::default()
};
if let Some(name) = arg
.name
.strip_suffix("...")
.or_else(|| arg.name.strip_suffix("…"))
{
arg.var = true;
arg.name = name.to_string();
}
let first = arg.name.chars().next().unwrap_or_default();
let last = arg.name.chars().last().unwrap_or_default();
match (first, last) {
('[', ']') => {
arg.name = arg.name[1..arg.name.len() - 1].to_string();
arg.required = false;
}
('<', '>') => {
arg.name = arg.name[1..arg.name.len() - 1].to_string();
}
_ => {}
}
if let Some(name) = arg.name.strip_prefix("-- ") {
arg.double_dash = SpecDoubleDashChoices::Required;
arg.name = name.to_string();
}
if !arg.var {
if let Some(name) = arg
.name
.strip_suffix("...")
.or_else(|| arg.name.strip_suffix("…"))
{
arg.var = true;
arg.name = name.to_string();
}
}
arg
}
}
impl FromStr for SpecArg {
type Err = UsageErr;
fn from_str(input: &str) -> std::result::Result<Self, UsageErr> {
if fixed_placeholders(input.strip_prefix("-- ").unwrap_or(input)).is_some_and(
|placeholders| {
placeholders
.windows(2)
.any(|pair| pair[0].starts_with('<') != pair[1].starts_with('<'))
},
) {
let message =
"fixed-arity placeholders must be either all required or all optional".to_string();
return Err(UsageErr::InvalidInput(
message,
(0, input.len()).into(),
miette::NamedSource::new("argument", input.to_string()),
));
}
Ok(input.into())
}
}
fn fixed_placeholders(input: &str) -> Option<Vec<&str>> {
if !input.bytes().any(|byte| byte.is_ascii_whitespace()) {
return None;
}
let placeholders: Vec<_> = input.split_whitespace().collect();
(placeholders.len() > 1
&& placeholders.iter().all(|placeholder| {
matches!(
(placeholder.chars().next(), placeholder.chars().last()),
(Some('<'), Some('>')) | (Some('['), Some(']'))
)
}))
.then_some(placeholders)
}
#[cfg(feature = "clap")]
pub(crate) fn default_values(arg: &clap::Arg) -> Vec<String> {
let raw = arg
.get_default_values()
.iter()
.map(|v| v.to_string_lossy().to_string());
match arg.get_value_delimiter() {
Some(delimiter) => raw
.flat_map(|v| {
v.split(delimiter)
.map(|part| part.to_string())
.collect::<Vec<_>>()
})
.collect(),
None => raw.collect(),
}
}
#[cfg(feature = "clap")]
pub(crate) fn value_bounds(source: &clap::Arg, target: &mut SpecArg, zero_values_supported: bool) {
if source.get_value_delimiter().is_some() {
return;
}
let Some(range) = source.get_num_args() else {
if target.value_names.len() > 1 {
let arity = target.value_names.len();
target.var = true;
target.var_min = Some(arity);
target.var_max = Some(arity);
}
return;
};
let min = range.min_values();
let max = range.max_values();
if max <= 1 || min == 0 && !zero_values_supported {
return;
}
target.var = true;
target.var_min = Some(min);
target.var_max = (max != usize::MAX).then_some(max);
}
#[cfg(feature = "clap")]
pub(crate) fn value_names_from_clap(source: &clap::Arg) -> Vec<String> {
let names: Vec<String> = source
.get_value_names()
.unwrap_or_default()
.iter()
.map(ToString::to_string)
.collect();
if names.len() <= 1 {
return names;
}
let mismatched_range = source.get_num_args().is_some_and(|range| {
range.min_values() != names.len() || range.max_values() != names.len()
});
if source.get_value_delimiter().is_some() || mismatched_range {
names.into_iter().take(1).collect()
} else {
names
}
}
#[cfg(feature = "clap")]
pub(crate) fn value_hint_type(hint: clap::ValueHint) -> Option<&'static str> {
use clap::ValueHint;
match hint {
ValueHint::Unknown => None,
ValueHint::Other => Some("none"),
ValueHint::AnyPath | ValueHint::FilePath => Some("path"),
ValueHint::DirPath => Some("dir"),
ValueHint::ExecutablePath => Some("executable"),
ValueHint::CommandName | ValueHint::CommandString => Some("command"),
ValueHint::CommandWithArguments => Some("command_args"),
ValueHint::Username => Some("username"),
ValueHint::Hostname => Some("hostname"),
ValueHint::Url => Some("url"),
ValueHint::EmailAddress => Some("email"),
_ => None,
}
}
#[cfg(feature = "clap")]
pub(crate) fn choices_from_clap(arg: &clap::Arg) -> Option<SpecChoices> {
let possible = arg.get_possible_values();
if possible.is_empty() {
return None;
}
let choices = possible
.iter()
.map(|value| value.get_name().to_string())
.collect();
let details = possible
.iter()
.filter_map(|value| {
let aliases: Vec<_> = value
.get_name_and_aliases()
.skip(1)
.map(|alias| SpecChoiceAlias {
value: alias.to_string(),
hide: true,
})
.collect();
let detail = SpecChoice {
value: value.get_name().to_string(),
help: value.get_help().map(ToString::to_string),
hide: value.is_hide_set(),
aliases,
};
(detail.help.is_some() || detail.hide || !detail.aliases.is_empty()).then_some(detail)
})
.collect();
Some(SpecChoices {
choices,
details,
ignore_case: arg.is_ignore_case_set(),
..Default::default()
})
}
#[cfg(feature = "clap")]
impl From<&clap::Arg> for SpecArg {
fn from(arg: &clap::Arg) -> Self {
let source = arg;
let required = arg.is_required_set();
let help = arg.get_help().map(|s| s.to_string());
let help_long = arg.get_long_help().map(|s| s.to_string());
let help_first_line = help.as_ref().map(|s| string::first_line(s));
let hide = arg.is_hide_set();
let delimiter = arg.get_value_delimiter();
let recorded_delimiter = delimiter.filter(char::is_ascii);
let value_terminator = arg.get_value_terminator().map(ToString::to_string);
let var = matches!(
arg.get_action(),
clap::ArgAction::Count | clap::ArgAction::Append
) || delimiter.is_some();
let choices = choices_from_clap(arg);
let value_names = value_names_from_clap(arg);
let mut arg = Self {
name: value_names
.first()
.cloned()
.unwrap_or_else(|| source.get_id().to_string()),
value_names,
usage: "".into(),
required,
double_dash: if arg.is_last_set() {
SpecDoubleDashChoices::Required
} else if arg.is_trailing_var_arg_set() {
SpecDoubleDashChoices::Automatic
} else {
SpecDoubleDashChoices::Optional
},
help,
help_long,
help_md: None,
help_first_line,
var,
var_max: None,
var_min: None,
delimiter: recorded_delimiter,
allow_negative_numbers: arg.is_allow_negative_numbers_set(),
value_terminator: None,
hide,
hide_default_value: arg.is_hide_default_value_set(),
hide_env: arg.is_hide_env_set(),
hide_env_values: arg.is_hide_env_values_set(),
hide_possible_values: arg.is_hide_possible_values_set(),
hide_short_help: arg.is_hide_short_help_set(),
hide_long_help: arg.is_hide_long_help_set(),
conflicts: Vec::new(),
requires: Vec::new(),
required_if: Vec::new(),
required_if_eq: Vec::new(),
required_if_eq_all: Vec::new(),
required_unless: Vec::new(),
required_unless_all: Vec::new(),
default: default_values(arg),
choices: None,
validate: None,
validate_error: None,
effect: None,
env: None,
env_fallback: Vec::new(),
deprecated_env: Vec::new(),
help_heading: arg.get_help_heading().map(|s| s.to_string()),
display_order: Some(arg.get_display_order()),
};
arg.choices = choices;
value_bounds(source, &mut arg, true);
if arg.var {
arg.value_terminator = value_terminator;
}
arg
}
}
impl Display for SpecArg {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.usage())
}
}
impl PartialEq for SpecArg {
fn eq(&self, other: &Self) -> bool {
self.name == other.name
}
}
impl Eq for SpecArg {}
impl Hash for SpecArg {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
self.name.hash(state);
}
}
#[cfg(all(test, feature = "validation"))]
mod validation_tests {
use std::collections::HashMap;
use crate::{parse, parse::Parser, Spec};
fn spec() -> Spec {
r#"
name "ex"
bin "ex"
arg "<port>" validate="int(value) >= 1 && int(value) <= 65535" validate_error="must be a valid port"
"#
.parse()
.unwrap()
}
#[test]
fn validation_round_trips_through_kdl() {
let spec = spec();
let kdl = spec.to_string();
let reparsed: Spec = kdl.parse().unwrap();
let arg = &reparsed.cmd.args[0];
assert_eq!(
arg.validate.as_deref(),
Some("int(value) >= 1 && int(value) <= 65535")
);
assert_eq!(arg.validate_error.as_deref(), Some("must be a valid port"));
}
#[test]
fn invalid_validation_declarations_are_rejected_with_the_spec() {
let missing_expression = r#"name "demo"
bin "demo"
arg "<port>" validate_error="must be a port"
"#;
assert!(missing_expression.parse::<Spec>().is_err());
let invalid_expression = r#"name "demo"
bin "demo"
arg "<port>" validate="int(value) >"
"#;
assert!(invalid_expression.parse::<Spec>().is_err());
}
#[test]
fn reference_parser_validates_each_raw_value() {
parse(&spec(), &["ex".to_string(), "9229".to_string()]).unwrap();
let error = parse(&spec(), &["ex".to_string(), "0".to_string()]).unwrap_err();
assert!(
error.to_string().contains("must be a valid port"),
"{error:?}"
);
let variadic: Spec = r#"
name "ex"
bin "ex"
arg "<port>" var=#true validate="int(value) > 0" validate_error="port must be positive"
"#
.parse()
.unwrap();
let error = parse(
&variadic,
&["ex".to_string(), "0".to_string(), "-1".to_string()],
)
.unwrap_err()
.to_string();
assert_eq!(error.matches("port must be positive").count(), 1, "{error}");
}
#[test]
fn reference_parser_validates_environment_and_default_fallbacks() {
let spec: Spec = r#"
name "ex"
bin "ex"
arg "[port]" env="PORT" validate="int(value) > 0" validate_error="port must be positive"
flag "--mode" default="bad" {
arg "<mode>" validate="value == 'good'" validate_error="mode must be good"
}
arg "[ports]..." env="PORTS" var=#true var_max=1 delimiter="," validate="int(value) > 0" validate_error="all ports must be positive"
flag "--levels" env="LEVELS" {
arg "<level>..." var=#true var_max=1 delimiter="," validate="value == 'good'" validate_error="all levels must be good"
}
flag "--modes" default="good,bad" {
arg "<mode>..." var=#true var_max=1 delimiter="," validate="value == 'good'" validate_error="all modes must be good"
}
flag "--conditional" {
default_if "--trigger" "good,bad"
arg "<conditional>..." var=#true var_max=1 delimiter="," validate="value == 'good'" validate_error="all conditional values must be good"
}
flag "--repeats <repeat>" env="REPEATS" var=#true var_max=1 delimiter=","
flag "--trigger"
"#
.parse()
.unwrap();
let env = HashMap::from([
("PORT".to_string(), "0".to_string()),
("PORTS".to_string(), "1,0".to_string()),
("LEVELS".to_string(), "good,bad".to_string()),
("REPEATS".to_string(), "one,two".to_string()),
]);
let error = Parser::new(&spec)
.with_env(env)
.parse(&["ex".to_string(), "--trigger".to_string()])
.unwrap_err();
let error = error.to_string();
assert!(error.contains("port must be positive"), "{error}");
assert!(error.contains("mode must be good"), "{error}");
assert!(error.contains("all ports must be positive"), "{error}");
assert!(error.contains("all levels must be good"), "{error}");
assert!(error.contains("all modes must be good"), "{error}");
assert!(
error.contains("all conditional values must be good"),
"{error}"
);
assert!(
error.contains("Variadic argument <ports> accepts at most 1 value(s), got 2"),
"{error}"
);
for flag in ["levels", "modes", "conditional", "repeats"] {
assert!(
error.contains(&format!(
"Variadic flag --{flag} accepts at most 1 value(s), got 2"
)),
"{error}"
);
}
}
}
#[cfg(test)]
mod delimiter_tests {
use crate::Spec;
#[test]
fn a_delimiter_has_to_be_one_byte() {
for spec in [
"flag \"--tags <tag>\" var=#true delimiter=\"§\"\n",
"arg \"[tags]...\" var=#true delimiter=\"、\"\n",
] {
let err = spec.parse::<Spec>().unwrap_err();
assert!(format!("{err:?}").contains("one byte"), "{err:?}");
}
let cmd = clap::Command::new("ex").arg(
clap::Arg::new("tags")
.long("tags")
.value_delimiter('、')
.action(clap::ArgAction::Set),
);
let spec = Spec::from(&cmd);
let arg = spec.cmd.flags[0].arg.as_ref().unwrap();
assert_eq!(
arg.delimiter, None,
"a separator it cannot write is not recorded"
);
assert!(arg.var, "clap still splits, so the values still arrive");
spec.to_string()
.parse::<Spec>()
.expect("what the bridge produces has to parse back");
}
#[test]
fn a_delimiter_round_trips_and_comes_across_from_clap() {
let spec: Spec = "flag \"--tags <tag>\" var=#true delimiter=\",\"\n"
.parse()
.unwrap();
let arg = spec.cmd.flags[0].arg.as_ref().unwrap();
assert_eq!(arg.delimiter, Some(','));
let reparsed: Spec = spec.to_string().parse().unwrap();
let arg = reparsed.cmd.flags[0].arg.as_ref().unwrap();
assert_eq!(arg.delimiter, Some(','), "{spec}");
let cmd = clap::Command::new("ex").arg(
clap::Arg::new("tags")
.long("tags")
.value_delimiter(',')
.num_args(1..)
.default_value("a,b"),
);
let spec = Spec::from(&cmd);
let flag = &spec.cmd.flags[0];
assert_eq!(flag.arg.as_ref().unwrap().delimiter, Some(','));
assert_eq!(flag.default, vec!["a", "b"]);
}
#[test]
fn a_single_valued_clap_arg_keeps_its_delimiter() {
let cmd = clap::Command::new("ex").arg(
clap::Arg::new("tags")
.long("tags")
.action(clap::ArgAction::Set)
.value_delimiter(','),
);
let spec = Spec::from(&cmd);
let arg = spec.cmd.flags[0].arg.as_ref().unwrap();
assert_eq!(arg.delimiter, Some(','));
assert!(arg.var, "a delimiter brings `var` with it");
let _: Spec = spec.to_string().parse().expect("{spec}");
}
#[test]
fn a_single_valued_clap_positional_splits_into_stored_values() {
let cmd = clap::Command::new("ex").arg(
clap::Arg::new("tags")
.action(clap::ArgAction::Set)
.value_delimiter(',')
.value_parser(["a", "b"]),
);
let spec = Spec::from(&cmd);
let arg = &spec.cmd.args[0];
assert!(arg.var, "a positional delimiter brings `var` with it");
assert_eq!(arg.delimiter, Some(','));
let input = ["ex", "a,b"].map(str::to_string);
let parsed = crate::parse(&spec, &input).expect("both split values are choices");
let value = parsed
.args
.values()
.next()
.expect("the positional was stored");
assert!(matches!(
value,
crate::parse::ParseValue::MultiString(values)
if values == &["a".to_string(), "b".to_string()]
));
}
#[test]
fn a_delimiter_needs_somewhere_to_put_what_it_splits() {
let err = "flag \"--tags <tag>\" delimiter=\",\"\n"
.parse::<Spec>()
.unwrap_err();
assert!(format!("{err:?}").contains("one value"), "{err:?}");
let err = "arg \"[tags]\" delimiter=\",\"\n"
.parse::<Spec>()
.unwrap_err();
assert!(format!("{err:?}").contains("one value"), "{err:?}");
let err = "flag \"--quiet\" delimiter=\",\"\n"
.parse::<Spec>()
.unwrap_err();
assert!(format!("{err:?}").contains("takes none"), "{err:?}");
let err = "flag \"--tags <tag>\" var=#true delimiter=\"::\"\n"
.parse::<Spec>()
.unwrap_err();
assert!(format!("{err:?}").contains("one character"), "{err:?}");
}
}
#[cfg(test)]
mod possible_value_tests {
use clap::builder::PossibleValue;
#[test]
fn clap_possible_value_metadata_survives_the_bridge() {
let command = clap::Command::new("ex").arg(
clap::Arg::new("color").ignore_case(true).value_parser([
PossibleValue::new("always")
.help("Always use color")
.alias("yes"),
PossibleValue::new("never").hide(true),
]),
);
let spec = crate::Spec::from(&command);
let choices = spec.cmd.args[0].choices.as_ref().unwrap();
assert_eq!(choices.choices, ["always", "never"]);
assert!(choices.ignore_case);
assert!(choices.matches("YES"));
assert_eq!(choices.values(), ["always"]);
assert_eq!(choices.details[0].help.as_deref(), Some("Always use color"));
assert!(choices.details[0].aliases[0].hide);
assert!(choices.details[1].hide);
}
}
#[cfg(test)]
mod tests {
use crate::{Spec, SpecArg};
use insta::assert_snapshot;
#[test]
fn test_arg_with_env() {
let spec = Spec::parse(
&Default::default(),
r#"
arg "<input>" env="MY_INPUT" help="Input file"
arg "<output>" env="MY_OUTPUT"
"#,
)
.unwrap();
assert_snapshot!(spec, @r#"
arg <input> help="Input file" env=MY_INPUT
arg <output> env=MY_OUTPUT
"#);
let input_arg = spec.cmd.args.iter().find(|a| a.name == "input").unwrap();
assert_eq!(input_arg.env, Some("MY_INPUT".to_string()));
let output_arg = spec.cmd.args.iter().find(|a| a.name == "output").unwrap();
assert_eq!(output_arg.env, Some("MY_OUTPUT".to_string()));
}
#[test]
fn test_arg_with_env_child_node() {
let spec = Spec::parse(
&Default::default(),
r#"
arg "<input>" help="Input file" {
env "MY_INPUT"
}
arg "<output>" {
env "MY_OUTPUT"
}
"#,
)
.unwrap();
assert_snapshot!(spec, @r#"
arg <input> help="Input file" env=MY_INPUT
arg <output> env=MY_OUTPUT
"#);
let input_arg = spec.cmd.args.iter().find(|a| a.name == "input").unwrap();
assert_eq!(input_arg.env, Some("MY_INPUT".to_string()));
let output_arg = spec.cmd.args.iter().find(|a| a.name == "output").unwrap();
assert_eq!(output_arg.env, Some("MY_OUTPUT".to_string()));
}
#[test]
fn test_arg_variadic_syntax() {
use crate::SpecArg;
let arg: SpecArg = "<files>...".into();
assert_eq!(arg.name, "files");
assert!(arg.var);
assert!(arg.required);
let arg: SpecArg = "[files]...".into();
assert_eq!(arg.name, "files");
assert!(arg.var);
assert!(!arg.required);
let arg: SpecArg = "<files>…".into();
assert_eq!(arg.name, "files");
assert!(arg.var);
let arg: SpecArg = "[files]…".into();
assert_eq!(arg.name, "files");
assert!(arg.var);
assert!(!arg.required);
let arg: SpecArg = "[args...]".into();
assert_eq!(arg.name, "args");
assert!(arg.var);
assert!(!arg.required);
let arg: SpecArg = "<args...>".into();
assert_eq!(arg.name, "args");
assert!(arg.var);
assert!(arg.required);
let arg: SpecArg = "[args…]".into();
assert_eq!(arg.name, "args");
assert!(arg.var);
assert!(!arg.required);
}
#[test]
fn fixed_arity_placeholders_round_trip() {
let spec: Spec = "arg \"<START> <END>\"\n".parse().unwrap();
let arg = &spec.cmd.args[0];
assert_eq!(arg.value_names, ["START", "END"]);
assert_eq!((arg.var_min, arg.var_max), (Some(2), Some(2)));
assert_eq!(arg.usage, "<START> <END>");
let reparsed: Spec = spec.to_string().parse().unwrap();
assert_eq!(reparsed.cmd.args[0].value_names, ["START", "END"]);
}
#[test]
fn fixed_arity_placeholders_reject_mismatched_bounds() {
let error = "arg \"<START> <END>\" var_min=1 var_max=2\n"
.parse::<Spec>()
.unwrap_err();
assert!(
format!("{error:?}").contains("require var_min=2 and var_max=2"),
"{error:?}"
);
}
#[test]
fn a_single_value_name_replaces_the_display_name() {
let spec: Spec = "arg \"<input>\" { value_names \"INPUT\" }\n"
.parse()
.unwrap();
let arg = &spec.cmd.args[0];
assert_eq!(arg.name, "INPUT");
assert_eq!(arg.usage, "<INPUT>");
let built = SpecArg::builder()
.name("input")
.required(true)
.value_names(["INPUT"])
.build();
assert_eq!(built.name, "INPUT");
assert_eq!(built.usage, "<INPUT>");
}
#[test]
fn builder_fixed_arity_survives_later_bound_setters() {
let after = SpecArg::builder()
.value_names(["START", "END"])
.var(false)
.var_min(1)
.var_max(4)
.build();
let before = SpecArg::builder()
.var(false)
.var_min(1)
.var_max(4)
.value_names(["START", "END"])
.build();
for arg in [after, before] {
assert!(arg.var);
assert_eq!((arg.var_min, arg.var_max), (Some(2), Some(2)));
assert_eq!(arg.usage, "[START] [END]");
}
}
#[test]
fn one_label_with_exact_bounds_renders_each_value_slot() {
let spec: Spec = "arg \"<item>…\" var_min=2 var_max=2 { value_names \"ITEM\" }\n"
.parse()
.unwrap();
assert_eq!(spec.cmd.args[0].usage, "<ITEM> <ITEM>");
let reparsed: Spec = spec.to_string().parse().unwrap();
assert_eq!(reparsed.cmd.args[0].value_names, ["ITEM", "ITEM"]);
assert_eq!(
(reparsed.cmd.args[0].var_min, reparsed.cmd.args[0].var_max),
(Some(2), Some(2))
);
let built = SpecArg::builder()
.value_names(["ITEM"])
.required(true)
.var(true)
.var_min(2)
.var_max(2)
.build();
assert_eq!(built.usage, "<ITEM> <ITEM>");
}
#[test]
fn fixed_arity_placeholders_reject_mixed_requiredness() {
let error = "arg \"<START> [END]\"\n".parse::<Spec>().unwrap_err();
assert!(
format!("{error:?}")
.contains("fixed-arity placeholders must be either all required or all optional"),
"{error:?}"
);
}
#[test]
fn test_arg_child_nodes() {
let spec = Spec::parse(
&Default::default(),
r#"
arg "<environment>" {
help "Deployment environment"
choices "dev" "staging" "prod"
}
arg "[services]" {
help "Services to deploy"
var #true
var_min 0
}
"#,
)
.unwrap();
let env_arg = spec
.cmd
.args
.iter()
.find(|a| a.name == "environment")
.unwrap();
assert_eq!(env_arg.help, Some("Deployment environment".to_string()));
assert!(env_arg.choices.is_some());
let svc_arg = spec.cmd.args.iter().find(|a| a.name == "services").unwrap();
assert_eq!(svc_arg.help, Some("Services to deploy".to_string()));
assert!(svc_arg.var);
assert_eq!(svc_arg.var_min, Some(0));
}
#[test]
fn test_arg_long_help_child_node() {
let spec = Spec::parse(
&Default::default(),
r#"
arg "<input>" {
help "Input file"
long_help "Extended help text for input"
}
"#,
)
.unwrap();
let input_arg = spec.cmd.args.iter().find(|a| a.name == "input").unwrap();
assert_eq!(input_arg.help, Some("Input file".to_string()));
assert_eq!(
input_arg.help_long,
Some("Extended help text for input".to_string())
);
}
#[test]
fn positional_conflicts_round_trip_without_dropping_members() {
let spec: Spec = "arg \"[VALUE]\" { conflicts \"--from-file\" \"--stdin\" }\n"
.parse()
.unwrap();
assert_eq!(
spec.cmd.args[0].conflicts,
vec!["--from-file".to_string(), "--stdin".to_string()]
);
let rendered = spec.to_string();
let reparsed: Spec = rendered.parse().unwrap();
assert_eq!(reparsed.cmd.args[0].conflicts, spec.cmd.args[0].conflicts);
}
}