pub mod arg;
pub mod builder;
pub mod choices;
pub mod cmd;
pub mod complete;
pub mod config;
pub mod config_type;
mod context;
pub mod data_types;
pub mod effect;
pub mod flag;
pub mod flagset;
pub mod group;
pub mod helpers;
pub mod mount;
pub mod unknown_flags;
pub mod view;
use indexmap::IndexMap;
use kdl::{KdlDocument, KdlEntry, KdlNode};
use log::{info, warn};
use regex::Regex;
use serde::Serialize;
use std::collections::HashMap;
use std::fmt::{Display, Formatter};
use std::iter::once;
use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::sync::LazyLock;
use crate::error::UsageErr;
use crate::spec::cmd::{SpecCommand, SpecExample};
use crate::spec::config::SpecConfig;
use crate::spec::context::ParsingContext;
use crate::spec::flagset::{SpecFlagSet, SpecUse};
use crate::spec::helpers::{string_entry, NodeHelper};
use crate::{SpecArg, SpecComplete, SpecFlag};
use view::SpecView;
#[derive(Debug, Default, Clone, Serialize)]
#[non_exhaustive]
pub struct Spec {
pub name: String,
pub bin: String,
pub cmd: SpecCommand,
pub config: SpecConfig,
#[serde(skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub long_version: Option<String>,
pub usage: String,
pub complete: IndexMap<String, SpecComplete>,
#[serde(skip_serializing_if = "IndexMap::is_empty")]
pub views: IndexMap<String, SpecView>,
#[serde(skip)]
pub flagsets: IndexMap<String, SpecFlagSet>,
#[serde(skip)]
pub sources: Vec<PathBuf>,
#[serde(skip_serializing_if = "Option::is_none")]
pub source_code_link_template: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub repository: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub author: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub about: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub about_long: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub about_md: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub license: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub before_help: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub after_help: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub before_help_long: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub after_help_long: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub help_template: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub disable_help: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub min_usage_version: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub examples: Vec<SpecExample>,
#[serde(skip_serializing_if = "Option::is_none")]
pub default_subcommand: Option<String>,
#[serde(default, skip_serializing_if = "is_false")]
pub multicall: bool,
#[doc(hidden)]
#[serde(skip)]
pub multicall_set: bool,
pub unknown_flags: Option<crate::spec::unknown_flags::UnknownFlags>,
}
impl Spec {
pub fn resolve_mount_outputs(
&mut self,
outputs: &HashMap<String, String>,
) -> Result<(), UsageErr> {
self.resolve_mount_outputs_at_root(outputs, true)
}
pub(crate) fn resolve_mount_outputs_at_root(
&mut self,
outputs: &HashMap<String, String>,
apply_default_subcommand: bool,
) -> Result<(), UsageErr> {
fn resolve(
cmd: &mut SpecCommand,
outputs: &HashMap<String, String>,
skip_mounts: bool,
) -> Result<(), UsageErr> {
if !skip_mounts && !cmd.mounts.is_empty() {
cmd.mount(&[], Some(outputs))?;
cmd.mounts.clear();
}
for subcommand in cmd.subcommands.values_mut() {
resolve(subcommand, outputs, false)?;
}
Ok(())
}
let default_outranks_root_mounts = apply_default_subcommand
&& self.default_subcommand.is_some()
&& !self.cmd.mounts.iter().any(|mount| mount.overrides_default);
resolve(&mut self.cmd, outputs, default_outranks_root_mounts)
}
#[must_use = "parsing result should be used"]
pub fn parse_file(file: &Path) -> Result<Spec, UsageErr> {
Self::parse_file_with_metadata_inference(file, true)
}
fn parse_file_with_metadata_inference(
file: &Path,
infer_metadata_from_filename: bool,
) -> Result<Spec, UsageErr> {
let spec = split_script(file)?;
let ctx = ParsingContext::new(file, &spec);
let mut schema = Self::parse(&ctx, &spec)?;
if infer_metadata_from_filename && schema.bin.is_empty() {
schema.bin = file
.file_name()
.and_then(|n| n.to_str())
.ok_or_else(|| UsageErr::InvalidPath(file.display().to_string()))?
.to_string();
}
if schema.name.is_empty() {
schema.name.clone_from(&schema.bin);
}
Ok(schema)
}
#[must_use = "parsing result should be used"]
pub fn parse_script(file: &Path) -> Result<Spec, UsageErr> {
let mut spec = Self::parse_script_with_path(&read_to_string(file)?, file)?;
if spec.bin.is_empty() {
spec.bin = file
.file_name()
.and_then(|n| n.to_str())
.ok_or_else(|| UsageErr::InvalidPath(file.display().to_string()))?
.to_string();
}
if spec.name.is_empty() {
spec.name.clone_from(&spec.bin);
}
Ok(spec)
}
#[must_use = "parsing result should be used"]
pub fn parse_script_str(input: &str) -> Result<Spec, UsageErr> {
Self::parse_script_with_path(input, Path::new(""))
}
fn parse_script_with_path(input: &str, file: &Path) -> Result<Spec, UsageErr> {
let raw = extract_usage_from_comments(input);
let ctx = ParsingContext::new(file, &raw);
Self::parse(&ctx, &raw)
}
#[deprecated]
pub fn parse_spec(input: &str) -> Result<Spec, UsageErr> {
Self::parse(&Default::default(), input)
}
pub fn is_empty(&self) -> bool {
self.name.is_empty()
&& self.bin.is_empty()
&& self.usage.is_empty()
&& self.cmd.is_empty()
&& self.config.is_empty()
&& self.complete.is_empty()
&& self.views.is_empty()
&& self.examples.is_empty()
}
pub fn for_view(&self, id: &str) -> Result<Spec, UsageErr> {
let view = self
.views
.get(id)
.ok_or_else(|| UsageErr::InvalidView(format!("spec declares no view named `{id}`")))?;
let mut command = &self.cmd;
for segment in view.root.split_whitespace() {
command = command.subcommands.get(segment).ok_or_else(|| {
UsageErr::InvalidView(format!(
"view `{id}` promotes `{}`, but `{segment}` is not a command on that path",
view.root
))
})?;
}
let mut promoted = command.clone();
let matches_selector = |flag: &SpecFlag, selector: &str| {
selector
.strip_prefix("--")
.is_some_and(|name| flag.long.iter().any(|long| long == name))
|| selector
.strip_prefix('-')
.filter(|short| short.len() == 1)
.and_then(|short| short.chars().next())
.is_some_and(|short| flag.short.contains(&short))
};
let carries = |flag: &SpecFlag| {
if !flag.global {
return false;
}
view.all_globals
|| view
.globals
.iter()
.any(|selector| matches_selector(flag, selector))
};
for selector in &view.globals {
if !self
.cmd
.flags
.iter()
.any(|flag| flag.global && matches_selector(flag, selector))
{
return Err(UsageErr::InvalidView(format!(
"view `{id}` carries `{selector}`, but it is not a root global flag"
)));
}
}
let globals: Vec<SpecFlag> = self
.cmd
.flags
.iter()
.filter(|flag| carries(flag) || flag.action == crate::SpecFlagAction::Version)
.cloned()
.collect();
let mut globals: Vec<SpecFlag> = globals
.into_iter()
.filter(|global| {
!promoted
.flags
.iter()
.any(|local| spec_flag_forms_overlap(global, local))
})
.collect();
let mut complete = IndexMap::new();
for flag in &globals {
if let Some(arg) = &flag.arg {
let name = arg.name.to_lowercase();
if let Some(completer) = self.complete.get(&name) {
complete.insert(name, completer.clone());
}
}
}
complete.extend(std::mem::take(&mut promoted.complete));
let mut carried_groups = Vec::new();
for group in &self.cmd.groups {
let members: Vec<String> = group
.members
.iter()
.filter(|selector| {
globals
.iter()
.any(|flag| flag_matches_selector(flag, selector))
})
.cloned()
.collect();
match members.as_slice() {
[only] if group.required => {
if let Some(flag) = globals
.iter_mut()
.find(|flag| flag_matches_selector(flag, only))
{
flag.required = true;
}
}
[_, _, ..] => {
let mut projected = group.clone();
projected.members = members;
carried_groups.push(projected);
}
_ => {}
}
}
promoted.flags.splice(0..0, globals);
promoted.groups.splice(0..0, carried_groups);
promoted.name.clone_from(&view.bin);
promoted.full_cmd.clear();
promoted.aliases.clear();
promoted.hidden_aliases.clear();
promoted.disable_version_flag = self.cmd.disable_version_flag;
set_subcommand_ancestors(&mut promoted, &[]);
promoted.usage = promoted.usage();
let mut spec = self.clone();
spec.name.clone_from(&view.name);
spec.bin.clone_from(&view.bin);
spec.about = promoted.help.clone();
spec.about_long = promoted.help_long.clone();
spec.about_md = promoted.help_md.clone();
spec.before_help = promoted.before_help.clone();
spec.before_help_long = promoted.before_help_long.clone();
spec.after_help = promoted.after_help.clone();
spec.after_help_long = promoted.after_help_long.clone();
spec.examples.clone_from(&promoted.examples);
spec.usage = promoted.usage.clone();
spec.complete = complete;
spec.cmd = promoted;
spec.default_subcommand = None;
spec.multicall = false;
spec.multicall_set = false;
spec.views.clear();
Ok(spec)
}
pub fn view_for_program(&self, program: &str) -> Option<&str> {
let basename = crate::parse::multicall_basename(program);
if basename == crate::parse::multicall_basename(&self.name)
|| (!self.bin.is_empty() && basename == crate::parse::multicall_basename(&self.bin))
{
return None;
}
self.views.values().find_map(|view| {
(basename == crate::parse::multicall_basename(&view.bin)
|| basename == crate::parse::multicall_basename(&view.id))
.then_some(view.id.as_str())
})
}
pub(crate) fn parse(ctx: &ParsingContext, input: &str) -> Result<Spec, UsageErr> {
let kdl: KdlDocument = input
.parse()
.map_err(|err: kdl::KdlError| UsageErr::KdlError(err))?;
let mut schema = Self {
..Default::default()
};
if !ctx.file.as_os_str().is_empty() {
schema.sources.push(ctx.file.clone());
}
for node in kdl.nodes().iter().map(|n| NodeHelper::new(ctx, n)) {
match node.name() {
"name" => schema.name = node.arg(0)?.ensure_string()?,
"bin" => {
schema.bin = node.arg(0)?.ensure_string()?;
if schema.name.is_empty() {
schema.name.clone_from(&schema.bin);
}
}
"version" => schema.version = Some(node.arg(0)?.ensure_string()?),
"long_version" => schema.long_version = Some(node.arg(0)?.ensure_string()?),
"author" => schema.author = Some(node.arg(0)?.ensure_string()?),
"source_code_link_template" => {
schema.source_code_link_template = Some(node.arg(0)?.ensure_string()?)
}
"repository" => schema.repository = Some(node.arg(0)?.ensure_string()?),
"about" => schema.about = Some(node.arg(0)?.ensure_string()?),
"long_about" => schema.about_long = Some(node.arg(0)?.ensure_string()?),
"about_long" => schema.about_long = Some(node.arg(0)?.ensure_string()?),
"about_md" => schema.about_md = Some(node.arg(0)?.ensure_string()?),
"license" => schema.license = Some(node.arg(0)?.ensure_string()?),
"before_help" => schema.before_help = Some(node.arg(0)?.ensure_string()?),
"after_help" => schema.after_help = Some(node.arg(0)?.ensure_string()?),
"before_long_help" | "before_help_long" => {
schema.before_help_long = Some(node.arg(0)?.ensure_string()?)
}
"after_long_help" | "after_help_long" => {
schema.after_help_long = Some(node.arg(0)?.ensure_string()?)
}
"usage" => schema.usage = node.arg(0)?.ensure_string()?,
"help_template" => {
let template = node.arg(0)?.ensure_string()?;
if let Err(problem) = crate::help_template::check(&template) {
bail_parse!(ctx, node.span(), "{problem}");
}
schema.help_template =
crate::help_template::is_set(&template).then_some(template);
}
"arg" => {
let arg = SpecArg::parse(ctx, &node)?;
if arg.delimiter.is_some() && !arg.var {
bail_parse!(
ctx,
node.node.name().span(),
"argument <{}> has a delimiter and holds one value; add \
`var=#true` for the values it splits into",
arg.name
);
}
schema.cmd.args.push(arg);
}
"flag" => schema.cmd.flags.push(SpecFlag::parse(ctx, &node)?),
"group" => schema.cmd.groups.push(crate::SpecGroup::parse(ctx, &node)?),
"mount" => schema.cmd.mounts.push(crate::SpecMount::parse(ctx, &node)?),
"cmd" => {
let node: SpecCommand = SpecCommand::parse(ctx, &node)?;
schema.cmd.subcommands.insert(node.name.to_string(), node);
}
"flagset" => {
let set = SpecFlagSet::parse(ctx, &node)?;
if schema.flagsets.insert(set.name.clone(), set).is_some() {
bail_parse!(ctx, node.span(), "a flagset may be declared only once");
}
}
"use" => {
let at = schema.cmd.flags.len();
schema.cmd.uses.push(SpecUse::parse(ctx, &node, at)?);
}
"config" => schema.config = SpecConfig::parse(ctx, &node)?,
"complete" => {
let complete = SpecComplete::parse(ctx, &node)?;
schema.complete.insert(complete.name.clone(), complete);
}
"view" => {
let view = SpecView::parse(ctx, &node)?;
if schema.views.insert(view.id.clone(), view).is_some() {
bail_parse!(
ctx,
node.span(),
"a view identifier may be declared only once"
);
}
}
"disable_help" => schema.disable_help = Some(node.arg(0)?.ensure_bool()?),
"min_usage_version" => {
let v = node.arg(0)?.ensure_string()?;
check_usage_version(&v);
schema.min_usage_version = Some(v);
}
"unknown_flags" => {
let raw = node.arg(0)?.ensure_string()?;
match raw.parse() {
Ok(mode) => schema.unknown_flags = Some(mode),
Err(_) => bail_parse!(
ctx,
node.span(),
"unsupported unknown_flags {raw}, expected one of: {}",
crate::spec::unknown_flags::UNKNOWN_FLAGS_VALUES
),
}
}
"default_subcommand" => {
schema.default_subcommand = Some(node.arg(0)?.ensure_string()?)
}
"multicall" => {
schema.multicall = node.arg(0)?.ensure_bool()?;
schema.multicall_set = true;
}
"external_subcommand" => {
schema.cmd.external_subcommand = node.arg(0)?.ensure_bool()?;
}
"arg_required_else_help" => {
schema.cmd.arg_required_else_help = node.arg(0)?.ensure_bool()?;
}
"disable_help_flag" => {
schema.cmd.disable_help_flag = node.arg(0)?.ensure_bool()?;
}
"disable_help_subcommand" => {
schema.cmd.disable_help_subcommand = node.arg(0)?.ensure_bool()?;
}
"disable_version_flag" => {
schema.cmd.disable_version_flag = node.arg(0)?.ensure_bool()?;
}
"dont_delimit_trailing_values" => {
schema.cmd.dont_delimit_trailing_values = node.arg(0)?.ensure_bool()?;
}
"args_override_self" => {
schema.cmd.args_override_self = node.arg(0)?.ensure_bool()?;
}
"subcommand_negates_reqs" => {
schema.cmd.subcommand_negates_reqs = node.arg(0)?.ensure_bool()?;
}
"args_conflicts_with_subcommands" => {
schema.cmd.args_conflicts_with_subcommands = node.arg(0)?.ensure_bool()?;
}
"subcommand_precedence_over_arg" => {
schema.cmd.subcommand_precedence_over_arg = node.arg(0)?.ensure_bool()?;
}
"allow_missing_positional" => {
schema.cmd.allow_missing_positional = node.arg(0)?.ensure_bool()?;
}
"deprecated" => schema.cmd.deprecated = Some(node.arg(0)?.ensure_string()?),
"deprecated_warn_at" => {
schema.cmd.deprecated_warn_at = Some(node.arg(0)?.ensure_string()?);
}
"deprecated_remove_at" => {
schema.cmd.deprecated_remove_at = Some(node.arg(0)?.ensure_string()?);
}
"subcommand_required" => {
schema.cmd.subcommand_required = node.arg(0)?.ensure_bool()?;
}
"subcommand_help_heading" => {
schema.cmd.subcommand_help_heading = Some(node.arg(0)?.ensure_string()?);
}
"subcommand_value_name" => {
schema.cmd.subcommand_value_name = Some(node.arg(0)?.ensure_string()?);
}
"next_line_help" => {
schema.cmd.next_line_help = node.arg(0)?.ensure_bool()?;
}
"flatten_help" => {
schema.cmd.flatten_help = node.arg(0)?.ensure_bool()?;
}
"term_width" => {
schema.cmd.term_width = Some(node.arg(0)?.ensure_usize()?);
}
"max_term_width" => {
schema.cmd.max_term_width = Some(node.arg(0)?.ensure_usize()?);
}
"example" => {
let code = node.ensure_arg_len(1..=1)?.arg(0)?.ensure_string()?;
let mut example = SpecExample::new(code.trim().to_string());
for (k, v) in node.props() {
match k {
"header" => example.header = Some(v.ensure_string()?),
"help" => example.help = Some(v.ensure_string()?),
"lang" => example.lang = v.ensure_string()?,
k => bail_parse!(ctx, v.entry.span(), "unsupported example key {k}"),
}
}
schema.examples.push(example);
}
"include" => {
let file = node
.props()
.get("file")
.map(|v| v.ensure_string())
.transpose()?
.ok_or_else(|| ctx.build_err("missing file".into(), node.span()))?;
let file = Path::new(&file);
let file = match file.is_relative() {
true => ctx
.file
.parent()
.ok_or_else(|| {
let msg = if ctx.file.as_os_str().is_empty() {
"relative includes require a source file".to_string()
} else {
format!("cannot get parent of {}", ctx.file.display())
};
ctx.build_err(msg, node.span())
})?
.join(file),
false => file.to_path_buf(),
};
info!("include: {}", file.display());
let other = Self::parse_file_with_metadata_inference(&file, false)?;
let clash = other.flagsets.values().find(|incoming| {
schema
.flagsets
.get(&incoming.name)
.is_some_and(|own| own.declared_in != incoming.declared_in)
});
if let Some(incoming) = clash {
let name = &incoming.name;
let owner = schema.flagsets[name].declared_in.clone();
let owner = match owner.as_os_str().is_empty() {
true => "this spec".to_string(),
false => owner.display().to_string(),
};
bail_parse!(
ctx,
node.span(),
"a flagset may be declared only once: \"{name}\" is declared in \
{} and in {owner}",
incoming.declared_in.display()
);
}
schema.merge(other);
}
k => bail_parse!(ctx, node.node.name().span(), "unsupported spec key {k}"),
}
}
schema.cmd.name = if schema.bin.is_empty() {
schema.name.clone()
} else {
schema.bin.clone()
};
flagset::expand(ctx, &mut schema.cmd, &mut schema.flagsets)?;
set_subcommand_ancestors(&mut schema.cmd, &[]);
Ok(schema)
}
pub fn merge(&mut self, other: Spec) {
macro_rules! merge_str {
($field:ident) => {
if !other.$field.is_empty() {
self.$field = other.$field;
}
};
}
macro_rules! merge_opt {
($field:ident) => {
if other.$field.is_some() {
self.$field = other.$field;
}
};
}
macro_rules! merge_extend {
($field:ident) => {
if !other.$field.is_empty() {
self.$field.extend(other.$field);
}
};
}
merge_str!(name);
merge_str!(bin);
merge_str!(usage);
merge_opt!(about);
merge_opt!(source_code_link_template);
merge_opt!(repository);
merge_opt!(version);
merge_opt!(long_version);
merge_opt!(author);
merge_opt!(about_long);
merge_opt!(about_md);
merge_opt!(license);
merge_opt!(before_help);
merge_opt!(after_help);
merge_opt!(before_help_long);
merge_opt!(after_help_long);
merge_opt!(help_template);
merge_opt!(disable_help);
merge_opt!(min_usage_version);
merge_opt!(default_subcommand);
if other.multicall_set {
self.multicall = other.multicall;
self.multicall_set = true;
}
merge_opt!(unknown_flags);
merge_extend!(complete);
merge_extend!(views);
merge_extend!(flagsets);
merge_extend!(examples);
merge_extend!(sources);
if !other.config.is_empty() {
self.config.merge(&other.config);
}
self.cmd.merge(other.cmd);
}
}
pub(crate) fn spec_flag_forms_overlap(a: &SpecFlag, b: &SpecFlag) -> bool {
fn long_forms(flag: &SpecFlag) -> impl Iterator<Item = &str> {
flag.long
.iter()
.chain(&flag.hidden_aliases)
.map(String::as_str)
.chain(
flag.negate
.as_deref()
.map(|name| name.strip_prefix("--").unwrap_or(name)),
)
}
fn short_forms(flag: &SpecFlag) -> impl Iterator<Item = &char> {
flag.short.iter().chain(&flag.hidden_short_aliases)
}
long_forms(a).any(|name| long_forms(b).any(|other| other == name))
|| short_forms(a).any(|name| short_forms(b).any(|other| other == name))
}
fn flag_matches_selector(flag: &SpecFlag, selector: &str) -> bool {
selector.strip_prefix("--").is_some_and(|name| {
flag.long
.iter()
.chain(&flag.hidden_aliases)
.any(|long| long == name)
|| flag
.negate
.as_deref()
.is_some_and(|negate| negate.strip_prefix("--").unwrap_or(negate) == name)
}) || selector
.strip_prefix('-')
.filter(|short| short.len() == 1)
.and_then(|short| short.chars().next())
.is_some_and(|short| {
flag.short
.iter()
.chain(&flag.hidden_short_aliases)
.any(|candidate| *candidate == short)
})
}
fn check_usage_version(version: &str) {
let cur = versions::Versioning::new(env!("CARGO_PKG_VERSION")).unwrap();
match versions::Versioning::new(version) {
Some(v) => {
if cur < v {
warn!(
"This usage spec requires at least version {version}, but you are using version {cur} of usage"
);
}
}
_ => warn!("Invalid version: {version}"),
}
}
fn read_to_string(file: &Path) -> Result<String, UsageErr> {
std::fs::read_to_string(file).map_err(|err| UsageErr::FileError(err, file.to_path_buf()))
}
static USAGE_COMMENT: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^(?:#|//|::)(?:USAGE| ?\[USAGE\])(.*)$").unwrap());
static HAS_USAGE_COMMENT: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^(?:#|//|::)(?:USAGE| ?\[USAGE\])").unwrap());
static BLANK_COMMENT: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^(?:#|//|::)\s*$").unwrap());
fn split_script(file: &Path) -> Result<String, UsageErr> {
let full = read_to_string(file)?;
if full.starts_with("#!") && full.lines().any(|l| HAS_USAGE_COMMENT.is_match(l)) {
return Ok(extract_usage_from_comments(&full));
}
Ok(full)
}
fn extract_usage_from_comments(full: &str) -> String {
let mut usage = vec![];
let mut found = false;
for line in full.lines() {
if let Some(captures) = USAGE_COMMENT.captures(line) {
found = true;
let content = captures.get(1).map_or("", |m| m.as_str());
usage.push(content.trim());
} else if found {
if BLANK_COMMENT.is_match(line) {
continue;
}
break;
}
}
usage.join("\n")
}
fn set_subcommand_ancestors(cmd: &mut SpecCommand, ancestors: &[String]) {
for subcmd in cmd.subcommands.values_mut() {
subcmd.full_cmd = ancestors
.iter()
.cloned()
.chain(once(subcmd.name.clone()))
.collect();
let child_ancestors = subcmd.full_cmd.clone();
set_subcommand_ancestors(subcmd, &child_ancestors);
}
if cmd.usage.is_empty() {
cmd.usage = cmd.usage();
}
}
impl Display for Spec {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
let mut doc = KdlDocument::new();
let nodes = &mut doc.nodes_mut();
if !self.name.is_empty() {
let mut node = KdlNode::new("name");
node.push(string_entry(None, &self.name));
nodes.push(node);
}
if !self.bin.is_empty() {
let mut node = KdlNode::new("bin");
node.push(string_entry(None, &self.bin));
nodes.push(node);
}
if let Some(version) = &self.version {
let mut node = KdlNode::new("version");
node.push(string_entry(None, version));
nodes.push(node);
}
if let Some(version) = &self.long_version {
let mut node = KdlNode::new("long_version");
node.push(string_entry(None, version));
nodes.push(node);
}
if let Some(author) = &self.author {
let mut node = KdlNode::new("author");
node.push(string_entry(None, author));
nodes.push(node);
}
if let Some(about) = &self.about {
let mut node = KdlNode::new("about");
node.push(string_entry(None, about));
nodes.push(node);
}
if let Some(source_code_link_template) = &self.source_code_link_template {
let mut node = KdlNode::new("source_code_link_template");
node.push(string_entry(None, source_code_link_template));
nodes.push(node);
}
if let Some(repository) = &self.repository {
let mut node = KdlNode::new("repository");
node.push(string_entry(None, repository));
nodes.push(node);
}
if let Some(about_md) = &self.about_md {
let mut node = KdlNode::new("about_md");
node.push(string_entry(None, about_md));
nodes.push(node);
}
if let Some(long_about) = &self.about_long {
let mut node = KdlNode::new("long_about");
node.push(string_entry(None, long_about));
nodes.push(node);
}
if let Some(license) = &self.license {
let mut node = KdlNode::new("license");
node.push(string_entry(None, license));
nodes.push(node);
}
if let Some(before_help) = &self.before_help {
let mut node = KdlNode::new("before_help");
node.push(string_entry(None, before_help));
nodes.push(node);
}
if let Some(after_help) = &self.after_help {
let mut node = KdlNode::new("after_help");
node.push(string_entry(None, after_help));
nodes.push(node);
}
if let Some(before_help_long) = &self.before_help_long {
let mut node = KdlNode::new("before_long_help");
node.push(string_entry(None, before_help_long));
nodes.push(node);
}
if let Some(after_help_long) = &self.after_help_long {
let mut node = KdlNode::new("after_long_help");
node.push(string_entry(None, after_help_long));
nodes.push(node);
}
if let Some(help_template) = &self.help_template {
let mut node = KdlNode::new("help_template");
node.push(string_entry(None, help_template));
nodes.push(node);
}
if let Some(disable_help) = self.disable_help {
let mut node = KdlNode::new("disable_help");
node.push(KdlEntry::new(disable_help));
nodes.push(node);
}
if let Some(min_usage_version) = &self.min_usage_version {
let mut node = KdlNode::new("min_usage_version");
node.push(string_entry(None, min_usage_version));
nodes.push(node);
}
if let Some(unknown_flags) = &self.unknown_flags {
let mut node = KdlNode::new("unknown_flags");
node.push(string_entry(None, unknown_flags.as_str()));
nodes.push(node);
}
if let Some(default_subcommand) = &self.default_subcommand {
let mut node = KdlNode::new("default_subcommand");
node.push(string_entry(None, default_subcommand));
nodes.push(node);
}
if self.multicall_set {
let mut node = KdlNode::new("multicall");
node.push(KdlEntry::new(self.multicall));
nodes.push(node);
}
if self.cmd.external_subcommand {
let mut node = KdlNode::new("external_subcommand");
node.push(KdlEntry::new(true));
nodes.push(node);
}
if self.cmd.arg_required_else_help {
let mut node = KdlNode::new("arg_required_else_help");
node.push(KdlEntry::new(true));
nodes.push(node);
}
if self.cmd.disable_help_flag {
let mut node = KdlNode::new("disable_help_flag");
node.push(KdlEntry::new(true));
nodes.push(node);
}
if self.cmd.disable_help_subcommand {
let mut node = KdlNode::new("disable_help_subcommand");
node.push(KdlEntry::new(true));
nodes.push(node);
}
if self.cmd.disable_version_flag {
let mut node = KdlNode::new("disable_version_flag");
node.push(KdlEntry::new(true));
nodes.push(node);
}
if self.cmd.dont_delimit_trailing_values {
let mut node = KdlNode::new("dont_delimit_trailing_values");
node.push(true);
nodes.push(node);
}
if !self.cmd.args_override_self {
let mut node = KdlNode::new("args_override_self");
node.push(false);
nodes.push(node);
}
if self.cmd.subcommand_negates_reqs {
let mut node = KdlNode::new("subcommand_negates_reqs");
node.push(true);
nodes.push(node);
}
if self.cmd.args_conflicts_with_subcommands {
let mut node = KdlNode::new("args_conflicts_with_subcommands");
node.push(true);
nodes.push(node);
}
if self.cmd.subcommand_precedence_over_arg {
let mut node = KdlNode::new("subcommand_precedence_over_arg");
node.push(true);
nodes.push(node);
}
if self.cmd.allow_missing_positional {
let mut node = KdlNode::new("allow_missing_positional");
node.push(true);
nodes.push(node);
}
if let Some(message) = &self.cmd.deprecated {
let mut node = KdlNode::new("deprecated");
node.push(string_entry(None, message));
nodes.push(node);
}
if let Some(at) = &self.cmd.deprecated_warn_at {
let mut node = KdlNode::new("deprecated_warn_at");
node.push(string_entry(None, at));
nodes.push(node);
}
if let Some(at) = &self.cmd.deprecated_remove_at {
let mut node = KdlNode::new("deprecated_remove_at");
node.push(string_entry(None, at));
nodes.push(node);
}
if self.cmd.subcommand_required && !self.cmd.subcommands.is_empty() {
let mut node = KdlNode::new("subcommand_required");
node.push(true);
nodes.push(node);
}
if let Some(heading) = &self.cmd.subcommand_help_heading {
let mut node = KdlNode::new("subcommand_help_heading");
node.push(string_entry(None, heading));
nodes.push(node);
}
if let Some(name) = &self.cmd.subcommand_value_name {
let mut node = KdlNode::new("subcommand_value_name");
node.push(string_entry(None, name));
nodes.push(node);
}
if self.cmd.next_line_help {
let mut node = KdlNode::new("next_line_help");
node.push(true);
nodes.push(node);
}
if self.cmd.flatten_help {
let mut node = KdlNode::new("flatten_help");
node.push(true);
nodes.push(node);
}
if let Some(width) = self.cmd.term_width {
let mut node = KdlNode::new("term_width");
node.push(width as i128);
nodes.push(node);
}
if let Some(width) = self.cmd.max_term_width {
let mut node = KdlNode::new("max_term_width");
node.push(width as i128);
nodes.push(node);
}
if !self.usage.is_empty() {
let mut node = KdlNode::new("usage");
node.push(string_entry(None, &self.usage));
nodes.push(node);
}
for flag in self.cmd.flags.iter() {
nodes.push(flag.into());
}
for arg in self.cmd.args.iter() {
nodes.push(arg.into());
}
for mount in self.cmd.mounts.iter() {
nodes.push(mount.into());
}
for group in self.cmd.groups.iter() {
nodes.push(group.into());
}
for example in self.examples.iter() {
nodes.push(example.into());
}
for complete in self.complete.values() {
nodes.push(complete.into());
}
for complete in self.cmd.complete.values() {
nodes.push(complete.into());
}
for view in self.views.values() {
let rendered: KdlDocument = view
.to_string()
.parse()
.expect("a view always renders valid KDL");
nodes.extend(rendered.nodes().iter().cloned());
}
for cmd in self.cmd.subcommands.values() {
nodes.push(cmd.into())
}
if !self.config.is_empty() {
nodes.push((&self.config).into());
}
doc.autoformat_config(&kdl::FormatConfigBuilder::new().build());
write!(f, "{doc}")
}
}
impl FromStr for Spec {
type Err = UsageErr;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::parse(&Default::default(), s)
}
}
#[cfg(feature = "clap")]
impl From<&clap::Command> for Spec {
fn from(cmd: &clap::Command) -> Self {
let mut spec = Spec {
name: cmd.get_name().to_string(),
bin: cmd.get_bin_name().unwrap_or(cmd.get_name()).to_string(),
cmd: cmd.into(),
version: cmd.get_version().map(|v| v.to_string()),
long_version: cmd.get_long_version().map(|v| v.to_string()),
about: cmd.get_about().map(|a| a.to_string()),
about_long: cmd.get_long_about().map(|a| a.to_string()),
usage: cmd.clone().render_usage().to_string(),
unknown_flags: crate::spec::cmd::SpecCommand::from(cmd).unknown_flags,
multicall: cmd.is_multicall_set(),
multicall_set: cmd.is_multicall_set(),
..Default::default()
};
set_subcommand_ancestors(&mut spec.cmd, &[]);
spec
}
}
#[inline]
pub fn is_true(b: &bool) -> bool {
*b
}
#[inline]
pub fn is_false(b: &bool) -> bool {
!is_true(b)
}
#[cfg(test)]
mod tests {
use super::*;
use insta::assert_snapshot;
#[test]
fn test_display() {
let spec = Spec::parse(
&Default::default(),
r#"
name "Usage CLI"
bin "usage"
arg "arg1"
flag "-f --force" global=#true
cmd "config" {
cmd "set" {
arg "key" help="Key to set"
arg "value"
}
}
complete "file" run="ls" descriptions=#true
"#,
)
.unwrap();
assert_snapshot!(spec, @r#"
name "Usage CLI"
bin usage
flag "-f --force" global=#true
arg <arg1>
complete file run=ls descriptions=#true
cmd config {
cmd set {
arg <key> help="Key to set"
arg <value>
}
}
"#);
}
#[test]
fn test_repository_round_trips() {
let spec = Spec::parse(
&Default::default(),
r#"
bin "mise"
repository "https://github.com/jdx/mise"
source_code_link_template "https://github.com/jdx/mise/blob/main/src/cli/{{path}}.rs"
"#,
)
.unwrap();
assert_eq!(
spec.repository.as_deref(),
Some("https://github.com/jdx/mise")
);
assert_snapshot!(spec, @r#"
name mise
bin mise
source_code_link_template "https://github.com/jdx/mise/blob/main/src/cli/{{path}}.rs"
repository "https://github.com/jdx/mise"
"#);
}
#[test]
fn test_repository_merges_like_the_other_optionals() {
let mut generated = Spec::parse(&Default::default(), r#"bin "mise""#).unwrap();
let extra = Spec::parse(
&Default::default(),
r#"repository "https://github.com/jdx/mise""#,
)
.unwrap();
generated.merge(extra);
assert_eq!(
generated.repository.as_deref(),
Some("https://github.com/jdx/mise")
);
}
#[test]
#[cfg(feature = "clap")]
fn test_clap() {
let cmd = clap::Command::new("test");
assert_snapshot!(Spec::from(&cmd), @r#"
name test
bin test
unknown_flags error
args_override_self #false
usage "Usage: test"
"#);
}
#[test]
#[cfg(feature = "clap")]
fn a_clap_subcommand_knows_where_it_sits() {
let cmd = clap::Command::new("ex").subcommand(
clap::Command::new("go")
.about("Go somewhere")
.subcommand(clap::Command::new("fast").about("Quickly")),
);
let spec = Spec::from(&cmd);
let go = spec.cmd.subcommands.get("go").expect("go");
assert_eq!(go.full_cmd, ["go"]);
assert_eq!(go.usage, "go <SUBCOMMAND>");
let fast = go.subcommands.get("fast").expect("fast");
assert_eq!(fast.full_cmd, ["go", "fast"]);
assert_eq!(fast.usage, "go fast");
}
#[test]
#[cfg(feature = "clap")]
fn a_delimited_default_becomes_the_values_clap_would_split_it_into() {
let cmd = clap::Command::new("test").arg(
clap::Arg::new("events")
.long("events")
.value_delimiter(',')
.action(clap::ArgAction::Append)
.value_parser(["a", "b", "c"])
.default_value("a,b"),
);
let spec = Spec::from(&cmd);
let flag = spec.cmd.flags.iter().find(|f| f.name == "events").unwrap();
assert_eq!(flag.default, ["a", "b"]);
let cmd = clap::Command::new("test")
.arg(clap::Arg::new("events").long("events").default_value("a,b"));
let spec = Spec::from(&cmd);
let flag = spec.cmd.flags.iter().find(|f| f.name == "events").unwrap();
assert_eq!(flag.default, ["a,b"]);
}
#[test]
fn multicall_round_trips() {
let spec = Spec::parse(
&Default::default(),
r#"
name "busybox"
bin "busybox"
multicall #true
cmd "ls"
cmd "cat"
"#,
)
.unwrap();
assert!(spec.multicall);
let emitted = spec.to_string();
assert!(
emitted.contains("multicall #true"),
"lost on the way out: {emitted}"
);
let again: Spec = emitted.parse().unwrap();
assert!(again.multicall);
}
#[test]
fn a_declared_view_promotes_a_command_and_selected_globals() {
let spec: Spec = r#"
name "aube"
bin "aube"
about_md "Host **markdown**"
before_help "host before"
before_long_help "host long before"
after_help "host after"
after_long_help "host long after"
example "aube host" header="host example"
flag "-v --verbose" global=#true
flag "--config <FILE>" global=#true
view "aubr" root="run" {
global "--verbose"
}
cmd "run" help="Run a package script" {
before_help "run before"
before_long_help "run long before"
after_help "run after"
after_long_help "run long after"
example "aubr task" header="run example"
flag "--if-present"
arg "[SCRIPT]"
cmd "nested"
}
"#
.parse()
.unwrap();
let rendered = spec.to_string();
assert!(rendered.contains("view aubr root=run"), "{rendered}");
let reparsed: Spec = rendered.parse().unwrap();
let applet = reparsed.for_view("aubr").unwrap();
assert_eq!(applet.name, "aubr");
assert_eq!(applet.bin, "aubr");
assert_eq!(applet.about.as_deref(), Some("Run a package script"));
assert_eq!(applet.about_md, None);
assert_eq!(applet.before_help.as_deref(), Some("run before"));
assert_eq!(applet.before_help_long.as_deref(), Some("run long before"));
assert_eq!(applet.after_help.as_deref(), Some("run after"));
assert_eq!(applet.after_help_long.as_deref(), Some("run long after"));
assert_eq!(applet.examples.len(), 1);
assert_eq!(applet.examples[0].header.as_deref(), Some("run example"));
assert!(applet.cmd.flags.iter().any(|flag| flag.name == "verbose"));
assert!(applet
.cmd
.flags
.iter()
.any(|flag| flag.name == "if-present"));
assert!(!applet.cmd.flags.iter().any(|flag| flag.name == "config"));
assert!(applet.cmd.subcommands.contains_key("nested"));
assert!(applet.views.is_empty());
assert!(applet.to_string().contains("bin aubr"));
}
#[test]
fn a_view_preserves_the_host_version_entry_policy() {
let spec: Spec = r#"
bin "host"
version "1.2.3"
disable_version_flag #true
view "runner" root=run
cmd "run"
"#
.parse()
.unwrap();
let view = spec.for_view("runner").unwrap();
assert!(view.cmd.disable_version_flag);
let emitted = view.to_string();
assert!(emitted.contains("disable_version_flag #true"), "{emitted}");
let reparsed: Spec = emitted.parse().unwrap();
assert!(reparsed.cmd.disable_version_flag);
}
#[test]
fn a_promoted_flag_shadows_every_carried_global_spelling() {
let spec: Spec = r#"
bin "host"
flag "--color" global=#true negate="--no-color"
view "runner" root=run {
global "--color"
}
cmd "run" {
flag "--no-color"
}
"#
.parse()
.unwrap();
let view = spec.for_view("runner").unwrap();
assert_eq!(view.cmd.flags.len(), 1);
assert_eq!(view.cmd.flags[0].long, ["no-color"]);
}
#[test]
fn a_view_keeps_only_its_own_and_carried_global_completers() {
let spec: Spec = r#"
bin "host"
flag "--host <HOST>" global=#true
flag "--carried <CARRIED>" global=#true
complete "host" run="host candidates"
complete "carried" run="carried candidates"
view "runner" root=run {
global "--carried"
}
cmd "run" {
arg "<HOST>"
complete "host" run="view candidates"
}
"#
.parse()
.unwrap();
let view = spec.for_view("runner").unwrap();
assert_eq!(
view.complete.get("host").unwrap().run.as_deref(),
Some("view candidates")
);
assert_eq!(
view.complete.get("carried").unwrap().run.as_deref(),
Some("carried candidates")
);
assert!(view.cmd.complete.is_empty());
assert_eq!(view.complete.len(), 2);
assert_eq!(view.to_string().matches("complete ").count(), 2);
}
#[test]
fn a_view_projects_groups_of_carried_globals() {
let spec: Spec = r#"
bin "host"
flag "--json" global=#true
flag "--yaml" global=#true
flag "--toml" global=#true
group "format" "--json" "--yaml" "--toml" required=#true
view "all" root=run globals=#true
view "json" root=run {
global "--json"
}
cmd "run"
"#
.parse()
.unwrap();
let all = spec.for_view("all").unwrap();
assert_eq!(all.cmd.groups.len(), 1);
assert_eq!(all.cmd.groups[0].members, ["--json", "--yaml", "--toml"]);
assert!(all.to_string().parse::<Spec>().is_ok());
let json = spec.for_view("json").unwrap();
assert!(json.cmd.groups.is_empty());
assert!(
json.cmd
.flags
.iter()
.find(|flag| flag.name == "json")
.unwrap()
.required
);
assert!(json.to_string().parse::<Spec>().is_ok());
}
#[test]
fn a_view_refuses_unknown_commands_and_non_global_carryovers() {
let missing: Spec = "bin \"ex\"\nview \"x\" root=missing\n".parse().unwrap();
assert!(missing
.for_view("x")
.unwrap_err()
.to_string()
.contains("missing"));
let local: Spec =
"bin \"ex\"\nflag \"--local\"\nview \"x\" root=go { global \"--local\" }\ncmd go\n"
.parse()
.unwrap();
assert!(local
.for_view("x")
.unwrap_err()
.to_string()
.contains("not a root global"));
}
#[test]
fn an_included_spec_can_enable_or_disable_multicall() {
let dir = tempfile::tempdir().unwrap();
let included = dir.path().join("included.usage.kdl");
let root = dir.path().join("root.usage.kdl");
std::fs::write(&included, "multicall #false\n").unwrap();
std::fs::write(
&root,
"multicall #true\ninclude file=\"./included.usage.kdl\"\n",
)
.unwrap();
let spec = Spec::parse_file(&root).unwrap();
assert!(!spec.multicall);
assert!(spec.to_string().contains("multicall #false"));
std::fs::write(&included, "multicall #true\n").unwrap();
std::fs::write(
&root,
"multicall #false\ninclude file=\"./included.usage.kdl\"\n",
)
.unwrap();
let spec = Spec::parse_file(&root).unwrap();
assert!(spec.multicall);
assert!(spec.to_string().contains("multicall #true"));
}
#[test]
#[cfg(feature = "clap")]
fn multicall_comes_across_from_clap() {
let cmd = clap::Command::new("busybox")
.multicall(true)
.subcommand(clap::Command::new("ls"))
.subcommand(clap::Command::new("cat"));
let spec = Spec::from(&cmd);
assert!(spec.multicall);
assert!(
spec.to_string().contains("multicall #true"),
"{}",
spec.to_string()
);
let plain = clap::Command::new("ex").subcommand(clap::Command::new("ls"));
assert!(!Spec::from(&plain).multicall);
}
macro_rules! extract_usage_tests {
($($name:ident: $input:expr, $expected:expr,)*) => {
$(
#[test]
fn $name() {
let result = extract_usage_from_comments($input);
let expected = $expected.trim_start_matches('\n').trim_end();
assert_eq!(result, expected);
}
)*
}
}
extract_usage_tests! {
test_extract_usage_from_comments_original_hash:
r#"
#!/bin/bash
#USAGE bin "test"
#USAGE flag "--foo" help="test"
echo "hello"
"#,
r#"
bin "test"
flag "--foo" help="test"
"#,
test_extract_usage_from_comments_original_double_slash:
r#"
#!/usr/bin/env node
//USAGE bin "test"
//USAGE flag "--foo" help="test"
console.log("hello");
"#,
r#"
bin "test"
flag "--foo" help="test"
"#,
test_extract_usage_from_comments_bracket_with_space:
r#"
#!/bin/bash
# [USAGE] bin "test"
# [USAGE] flag "--foo" help="test"
echo "hello"
"#,
r#"
bin "test"
flag "--foo" help="test"
"#,
test_extract_usage_from_comments_bracket_no_space:
r#"
#!/bin/bash
#[USAGE] bin "test"
#[USAGE] flag "--foo" help="test"
echo "hello"
"#,
r#"
bin "test"
flag "--foo" help="test"
"#,
test_extract_usage_from_comments_double_slash_bracket_with_space:
r#"
#!/usr/bin/env node
// [USAGE] bin "test"
// [USAGE] flag "--foo" help="test"
console.log("hello");
"#,
r#"
bin "test"
flag "--foo" help="test"
"#,
test_extract_usage_from_comments_double_slash_bracket_no_space:
r#"
#!/usr/bin/env node
//[USAGE] bin "test"
//[USAGE] flag "--foo" help="test"
console.log("hello");
"#,
r#"
bin "test"
flag "--foo" help="test"
"#,
test_extract_usage_from_comments_stops_at_gap:
r#"
#!/bin/bash
#USAGE bin "test"
#USAGE flag "--foo" help="test"
#USAGE flag "--bar" help="should not be included"
echo "hello"
"#,
r#"
bin "test"
flag "--foo" help="test"
"#,
test_extract_usage_from_comments_with_content_after_marker:
r#"
#!/bin/bash
# [USAGE] bin "test"
# [USAGE] flag "--verbose" help="verbose mode"
# [USAGE] arg "input" help="input file"
echo "hello"
"#,
r#"
bin "test"
flag "--verbose" help="verbose mode"
arg "input" help="input file"
"#,
test_extract_usage_from_comments_double_colon_original:
r#"
::USAGE bin "test"
::USAGE flag "--foo" help="test"
echo "hello"
"#,
r#"
bin "test"
flag "--foo" help="test"
"#,
test_extract_usage_from_comments_double_colon_bracket_with_space:
r#"
:: [USAGE] bin "test"
:: [USAGE] flag "--foo" help="test"
echo "hello"
"#,
r#"
bin "test"
flag "--foo" help="test"
"#,
test_extract_usage_from_comments_double_colon_bracket_no_space:
r#"
::[USAGE] bin "test"
::[USAGE] flag "--foo" help="test"
echo "hello"
"#,
r#"
bin "test"
flag "--foo" help="test"
"#,
test_extract_usage_from_comments_double_colon_stops_at_gap:
r#"
::USAGE bin "test"
::USAGE flag "--foo" help="test"
::USAGE flag "--bar" help="should not be included"
echo "hello"
"#,
r#"
bin "test"
flag "--foo" help="test"
"#,
test_extract_usage_from_comments_double_colon_with_content_after_marker:
r#"
::USAGE bin "test"
::USAGE flag "--verbose" help="verbose mode"
::USAGE arg "input" help="input file"
echo "hello"
"#,
r#"
bin "test"
flag "--verbose" help="verbose mode"
arg "input" help="input file"
"#,
test_extract_usage_from_comments_double_colon_bracket_with_space_multiple_lines:
r#"
:: [USAGE] bin "myapp"
:: [USAGE] flag "--config <file>" help="config file"
:: [USAGE] flag "--verbose" help="verbose output"
:: [USAGE] arg "input" help="input file"
:: [USAGE] arg "[output]" help="output file" required=#false
echo "done"
"#,
r#"
bin "myapp"
flag "--config <file>" help="config file"
flag "--verbose" help="verbose output"
arg "input" help="input file"
arg "[output]" help="output file" required=#false
"#,
test_extract_usage_from_comments_empty:
r#"
#!/bin/bash
echo "hello"
"#,
"",
test_extract_usage_from_comments_lowercase_usage:
r#"
#!/bin/bash
#usage bin "test"
#usage flag "--foo" help="test"
echo "hello"
"#,
"",
test_extract_usage_from_comments_mixed_case_usage:
r#"
#!/bin/bash
#Usage bin "test"
#Usage flag "--foo" help="test"
echo "hello"
"#,
"",
test_extract_usage_from_comments_space_before_usage:
r#"
#!/bin/bash
# USAGE bin "test"
# USAGE flag "--foo" help="test"
echo "hello"
"#,
"",
test_extract_usage_from_comments_double_slash_lowercase:
r#"
#!/usr/bin/env node
//usage bin "test"
//usage flag "--foo" help="test"
console.log("hello");
"#,
"",
test_extract_usage_from_comments_double_slash_mixed_case:
r#"
#!/usr/bin/env node
//Usage bin "test"
//Usage flag "--foo" help="test"
console.log("hello");
"#,
"",
test_extract_usage_from_comments_double_slash_space_before_usage:
r#"
#!/usr/bin/env node
// USAGE bin "test"
// USAGE flag "--foo" help="test"
console.log("hello");
"#,
"",
test_extract_usage_from_comments_bracket_lowercase:
r#"
#!/bin/bash
#[usage] bin "test"
#[usage] flag "--foo" help="test"
echo "hello"
"#,
"",
test_extract_usage_from_comments_bracket_mixed_case:
r#"
#!/bin/bash
#[Usage] bin "test"
#[Usage] flag "--foo" help="test"
echo "hello"
"#,
"",
test_extract_usage_from_comments_bracket_space_lowercase:
r#"
#!/bin/bash
# [usage] bin "test"
# [usage] flag "--foo" help="test"
echo "hello"
"#,
"",
test_extract_usage_from_comments_double_colon_lowercase:
r#"
::usage bin "test"
::usage flag "--foo" help="test"
echo "hello"
"#,
"",
test_extract_usage_from_comments_double_colon_mixed_case:
r#"
::Usage bin "test"
::Usage flag "--foo" help="test"
echo "hello"
"#,
"",
test_extract_usage_from_comments_double_colon_space_before_usage:
r#"
:: USAGE bin "test"
:: USAGE flag "--foo" help="test"
echo "hello"
"#,
"",
test_extract_usage_from_comments_double_colon_bracket_lowercase:
r#"
::[usage] bin "test"
::[usage] flag "--foo" help="test"
echo "hello"
"#,
"",
test_extract_usage_from_comments_double_colon_bracket_mixed_case:
r#"
::[Usage] bin "test"
::[Usage] flag "--foo" help="test"
echo "hello"
"#,
"",
test_extract_usage_from_comments_double_colon_bracket_space_lowercase:
r#"
:: [usage] bin "test"
:: [usage] flag "--foo" help="test"
echo "hello"
"#,
"",
}
#[test]
fn test_spec_with_examples() {
let spec = Spec::parse(
&Default::default(),
r#"
name "demo"
bin "demo"
example "demo --help" header="Getting help" help="Display help information"
example "demo --version" header="Check version"
"#,
)
.unwrap();
assert_eq!(spec.examples.len(), 2);
assert_eq!(spec.examples[0].code, "demo --help");
assert_eq!(spec.examples[0].header, Some("Getting help".to_string()));
assert_eq!(
spec.examples[0].help,
Some("Display help information".to_string())
);
assert_eq!(spec.examples[1].code, "demo --version");
assert_eq!(spec.examples[1].header, Some("Check version".to_string()));
assert_eq!(spec.examples[1].help, None);
}
#[test]
fn test_spec_examples_display() {
let spec = Spec::parse(
&Default::default(),
r#"
name "demo"
bin "demo"
example "demo --help" header="Getting help" help="Show help"
example "demo --version"
"#,
)
.unwrap();
let output = format!("{}", spec);
assert!(
output.contains("example \"demo --help\" header=\"Getting help\" help=\"Show help\"")
);
assert!(output.contains("example \"demo --version\""));
}
#[test]
fn test_parse_script_str() {
let spec = Spec::parse_script_str(
r#"
#!/bin/bash
#USAGE bin "test"
#USAGE flag "--foo" help="test"
echo "hello"
"#,
)
.unwrap();
assert_eq!(spec.bin, "test");
assert_eq!(spec.name, "test");
assert_eq!(spec.cmd.flags.len(), 1);
assert_eq!(spec.cmd.flags[0].long, ["foo"]);
}
#[test]
fn test_parse_script_str_rejects_relative_includes() {
let err = Spec::parse_script_str(r#"#USAGE include file="relative.usage.kdl""#)
.expect_err("relative includes need a source path");
match err {
UsageErr::InvalidInput(msg, _, _) => {
assert_eq!(msg, "relative includes require a source file");
}
err => panic!("unexpected error: {err:?}"),
}
}
#[test]
fn test_include_does_not_infer_metadata_from_included_filename() {
let dir = tempfile::tempdir().unwrap();
let included = dir.path().join("overrides.usage.kdl");
let root = dir.path().join("my-script.usage.kdl");
std::fs::write(&included, "").unwrap();
std::fs::write(&root, "include file=\"./overrides.usage.kdl\"\n").unwrap();
let spec = Spec::parse_file(&root).unwrap();
assert_eq!(spec.name, "my-script.usage.kdl");
assert_eq!(spec.bin, "my-script.usage.kdl");
assert!(spec.cmd.name.is_empty());
}
#[test]
fn injected_nested_mounts_ignore_the_mounted_specs_root_default() {
let mut spec: Spec = "mount run=outer".parse().unwrap();
let outputs = HashMap::from([
(
"outer".to_string(),
"default_subcommand run\nmount run=nested\ncmd run".to_string(),
),
("nested".to_string(), "cmd leaf".to_string()),
]);
spec.resolve_mount_outputs(&outputs).unwrap();
assert!(spec.cmd.subcommands.contains_key("leaf"));
}
}