mod structs;
use std::collections::{BTreeMap, HashMap};
use std::fmt::Write as _;
use heck::AsPascalCase;
use crate::spec::unknown_flags::UnknownFlags;
use crate::{
Spec, SpecArg, SpecChoices, SpecCommand, SpecDoubleDashChoices, SpecFlag, SpecFlagAction,
};
#[derive(Debug, Clone, Default)]
pub struct GoOptions {
pub package: Option<String>,
}
pub fn generate(spec: &Spec, opts: &GoOptions) -> String {
Emitter::new(spec, opts).run()
}
struct Named {
key: String,
var: String,
number: u64,
}
struct Emitter<'a> {
spec: &'a Spec,
package: String,
taken: HashMap<String, u32>,
next_key: u64,
out: String,
}
impl<'a> Emitter<'a> {
fn new(spec: &'a Spec, opts: &GoOptions) -> Self {
let package = match opts.package.as_deref() {
Some(name) if is_valid_package(name) => name.to_string(),
Some(name) => package_ident(name),
None => package_ident(&spec.bin),
};
Emitter {
spec,
package,
taken: HashMap::new(),
next_key: 0,
out: String::new(),
}
}
fn unique(&mut self, base: &str) -> String {
let mut n = self.taken.get(base).copied().unwrap_or(0);
loop {
n += 1;
let candidate = if n == 1 {
base.to_string()
} else {
format!("{base}{n}")
};
if !self.taken.contains_key(&candidate) {
self.taken.insert(base.to_string(), n);
self.taken.entry(candidate.clone()).or_insert(0);
return candidate;
}
}
}
fn name(&mut self, prefix: &str, path: &[&str], own: &str) -> Named {
let mut base = String::from(prefix);
for segment in path {
let _ = write!(base, "{}", AsPascalCase(segment));
}
let _ = write!(base, "{}", AsPascalCase(own));
let key = self.unique(&base);
self.next_key += 1;
Named {
var: format!("cmd{}", &key[prefix.len()..]),
key,
number: self.next_key,
}
}
fn run(mut self) -> String {
let mut commands = Vec::new();
self.collect(&self.spec.cmd.clone(), &[], true, &mut commands);
self.header();
self.constants(&commands);
self.tables(&commands);
self.metadata(&commands);
self.help_table(&commands);
structs::emit(&mut self.out, &commands);
let trimmed = self.out.trim_end().len();
self.out.truncate(trimmed);
self.out.push('\n');
self.out
}
fn collect(&mut self, cmd: &SpecCommand, path: &[&str], root: bool, out: &mut Vec<Emitted>) {
let named = if root {
self.next_key += 1;
Named {
key: self.unique("CmdRoot"),
var: "Root".to_string(),
number: self.next_key,
}
} else {
self.name("Cmd", &path[..path.len() - 1], path[path.len() - 1])
};
let flags = cmd
.flags
.iter()
.map(|f| (f.clone(), self.name("Flag", path, &f.name)))
.collect::<Vec<_>>();
let args = cmd
.args
.iter()
.map(|a| (a.clone(), self.name("Arg", path, &a.name)))
.collect::<Vec<_>>();
let index = out.len();
out.push(Emitted {
named,
cmd: cmd.clone(),
flags,
args,
subcommands: Vec::new(),
root,
});
let mut children = Vec::new();
for (name, sub) in &cmd.subcommands {
if name != &sub.name {
continue;
}
let mut child_path = path.to_vec();
child_path.push(name);
let at = out.len();
self.collect(sub, &child_path, false, out);
children.push(at);
}
out[index].subcommands = children;
}
fn header(&mut self) {
let _ = writeln!(
self.out,
"// Code generated by `usage generate go`. DO NOT EDIT.\n\
//\n\
// Binding tables for `{}`, read by\n\
// [github.com/jdx/usage/go/argv]. Regenerate rather than editing: the spec is\n\
// the definition, and a hand-edit here is a difference no reviewer can see.\n\
//\n\
// These are package-level variables holding plain data, so the linker lays them\n\
// out and nothing runs before main.\n\
\n\
package {}\n\
\n\
import \"github.com/jdx/usage/go/argv\"\n",
self.spec.bin, self.package
);
if let Some(version) = self
.spec
.version
.as_ref()
.or(self.spec.long_version.as_ref())
{
let _ = writeln!(
self.out,
"// Version is what the spec declares, so a caller answering `--version` has it\n\
// without the parse tables carrying a string binding never reads.\n\
const Version = {}\n",
go_string(version)
);
}
if let Some(version) = &self.spec.long_version {
let _ = writeln!(
self.out,
"// LongVersion is the extended text printed for `--version`; `-V` uses Version.\n\
const LongVersion = {}\n",
go_string(version)
);
}
}
fn constants(&mut self, commands: &[Emitted]) {
let _ = writeln!(
self.out,
"// Keys identify a table entry without a string comparison: switch on the Key an\n\
// event carries rather than on its Name, which is there for diagnostics.\n\
const ("
);
let mut entries: Vec<(&str, u64)> = Vec::new();
for e in commands {
entries.push((&e.named.key, e.named.number));
entries.extend(e.flags.iter().map(|(_, n)| (n.key.as_str(), n.number)));
entries.extend(e.args.iter().map(|(_, n)| (n.key.as_str(), n.number)));
}
let width = entries.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
for (key, number) in entries {
let _ = writeln!(
self.out,
"\t{key}{:pad$} uint64 = {number}",
"",
pad = width - key.len()
);
}
let _ = writeln!(self.out, ")\n");
}
fn tables(&mut self, commands: &[Emitted]) {
let default_subcommand = self.spec.default_subcommand.as_ref().and_then(|name| {
let direct = || commands[0].subcommands.iter().map(|at| &commands[*at]);
direct()
.find(|e| &e.cmd.name == name)
.or_else(|| {
direct().find(|e| {
e.cmd.aliases.contains(name) || e.cmd.hidden_aliases.contains(name)
})
})
.map(|e| e.named.var.clone())
});
for (i, e) in commands.iter().enumerate() {
let doc = if e.root {
format!(
"// Root is the command tree for `{}`. Pass it to argv.New.",
self.spec.bin
)
} else {
format!("// {}", e.cmd.full_cmd.join(" "))
};
let mut lines = vec![
Line::Field("Name".into(), go_string(&e.cmd.name)),
Line::Field("Key".into(), e.named.key.clone()),
];
let aliases: Vec<&String> = e
.cmd
.aliases
.iter()
.chain(e.cmd.hidden_aliases.iter())
.collect();
if !aliases.is_empty() {
let list = aliases
.iter()
.map(|a| go_string(a))
.collect::<Vec<_>>()
.join(", ");
lines.push(Line::Field("Aliases".into(), format!("[]string{{{list}}}")));
}
if !e.flags.is_empty() {
let mut block = vec!["Flags: []*argv.Flag{".to_string()];
for (flag, named) in &e.flags {
block.push(format!("\t{},", flag_literal(flag, named)));
}
block.push("},".to_string());
lines.push(Line::Block(block));
}
if !e.args.is_empty() {
let mut block = vec!["Args: []*argv.Arg{".to_string()];
for (arg, named) in &e.args {
block.push(format!("\t{},", arg_literal(arg, named)));
}
block.push("},".to_string());
lines.push(Line::Block(block));
}
if !e.subcommands.is_empty() {
let list = e
.subcommands
.iter()
.map(|at| commands[*at].named.var.clone())
.collect::<Vec<_>>()
.join(", ");
lines.push(Line::Field(
"Subcommands".into(),
format!("[]*argv.Command{{{list}}}"),
));
}
if effective_unknown_flags(self.spec, commands, i) == UnknownFlags::Error {
lines.push(Line::Field(
"UnknownFlags".into(),
"argv.UnknownFlagsError".into(),
));
}
if e.cmd.external_subcommand {
lines.push(Line::Field("ExternalSubcommand".into(), "true".into()));
}
if e.cmd.arg_required_else_help {
lines.push(Line::Field("ArgRequiredElseHelp".into(), "true".into()));
}
if e.cmd.disable_help_flag {
lines.push(Line::Field("DisableHelpFlag".into(), "true".into()));
}
if e.cmd.disable_help_subcommand {
lines.push(Line::Field("DisableHelpSubcommand".into(), "true".into()));
}
if e.cmd.disable_version_flag {
lines.push(Line::Field("DisableVersionFlag".into(), "true".into()));
}
if e.cmd.subcommand_negates_reqs {
lines.push(Line::Field("SubcommandNegatesReqs".into(), "true".into()));
}
if e.cmd.args_conflicts_with_subcommands {
lines.push(Line::Field(
"ArgsConflictWithSubcommands".into(),
"true".into(),
));
}
if e.cmd.subcommand_precedence_over_arg {
lines.push(Line::Field(
"SubcommandPrecedenceOverArg".into(),
"true".into(),
));
}
if e.cmd.allow_missing_positional {
lines.push(Line::Field("AllowMissingPositional".into(), "true".into()));
}
if e.cmd.dont_delimit_trailing_values {
lines.push(Line::Field(
"DontDelimitTrailingValues".into(),
"true".into(),
));
}
if e.root {
if let Some(var) = &default_subcommand {
lines.push(Line::Field("DefaultSubcommand".into(), var.clone()));
}
if self.spec.version.is_some() || self.spec.long_version.is_some() {
lines.push(Line::Field("Version".into(), "true".into()));
}
}
let _ = writeln!(self.out, "{doc}");
let _ = writeln!(self.out, "var {} = &argv.Command{{", e.named.var);
render(&mut self.out, "\t", &lines);
let _ = writeln!(self.out, "}}\n");
}
}
}
impl Emitter<'_> {
fn metadata(&mut self, commands: &[Emitted]) {
let mut by_key: BTreeMap<u64, String> = BTreeMap::new();
for e in commands {
for (flag, named) in &e.flags {
by_key.insert(named.number, self.flag_meta(flag, named, e, commands));
}
for (arg, named) in &e.args {
by_key.insert(named.number, arg_meta(self.spec, arg, named, e, commands));
}
}
let total = commands
.iter()
.map(|e| 1 + e.flags.len() + e.args.len())
.sum::<usize>() as u64;
let _ = writeln!(
self.out,
"// Meta is the cold table, read only by the rules that are decided once the\n\
// last token has been read: required, choices, the env-then-default fallback,\n\
// the var bounds, and the four that compare one entry against another. A parse\n\
// never touches it.\n\
//\n\
// Indexed by key, so entry Key sits at Meta[Key-1]. A command's slot is empty:\n\
// commands take keys too, and have no cold half.\n\
var Meta = argv.Metadata{{"
);
for key in 1..=total {
match by_key.get(&key) {
Some(entry) => {
let _ = writeln!(self.out, "\t{entry},");
}
None => {
let _ = writeln!(self.out, "\t{{}},");
}
}
}
let _ = writeln!(self.out, "}}\n");
}
fn flag_meta(
&self,
flag: &SpecFlag,
named: &Named,
owner: &Emitted,
commands: &[Emitted],
) -> String {
let mut fields = vec![
format!("Key: {}", named.key),
format!("Name: {}", go_string(&flag.name)),
"Flag: true".to_string(),
];
if !owner.cmd.args_override_self
&& !flag.var
&& !flag.count
&& !flag.arg.as_ref().is_some_and(|arg| arg.var)
{
fields.push("RejectDuplicate: true".to_string());
}
if flag.required {
fields.push("Required: true".to_string());
}
if flag.arg.is_none() {
fields.push("RequiresIfBoolean: true".to_string());
}
if let Some(long) = flag.long.first() {
fields.push(format!("Spelling: {}", go_string(&format!("--{long}"))));
} else if let Some(short) = flag.short.first() {
fields.push(format!("Spelling: {}", go_string(&format!("-{short}"))));
}
if let Some(value) = flag.arg.as_ref() {
fields.push(format!("ValueName: {}", go_string(&value.name)));
}
let named_value = flag
.arg
.as_ref()
.map(|a| a.name.as_str())
.unwrap_or(flag.name.as_str());
if let Some(kind) = complete_type(self.spec, named_value) {
fields.push(format!("CompleteType: {}", go_string(kind)));
}
if let Some(choices) = flag.arg.as_ref().and_then(|a| a.choices.as_ref()) {
fields.push(format!(
"Choices: {}",
string_slice(&visible_choices(choices))
));
fields.push(format!(
"AcceptedChoices: {}",
string_slice(&accepted_choices(choices))
));
if choices.ignore_case {
fields.push("IgnoreCase: true".to_string());
}
if !choices.strict {
fields.push("AllowUnknownChoices: true".to_string());
}
}
let default = if !flag.default.is_empty() {
&flag.default
} else {
flag.arg
.as_ref()
.map(|a| &a.default)
.unwrap_or(&flag.default)
};
if !default.is_empty() {
fields.push(format!("Default: {}", string_slice(default)));
}
if let Some(env) = &flag.env {
fields.push(format!("Env: {}", go_string(env)));
}
if !flag.env_fallback.is_empty() {
fields.push(format!("EnvFallback: {}", string_slice(&flag.env_fallback)));
}
if !flag.deprecated_env.is_empty() {
fields.push(format!(
"DeprecatedEnv: {}",
string_slice(&flag.deprecated_env)
));
}
let minimum = flag
.arg
.as_ref()
.filter(|arg| arg.var)
.and_then(|arg| arg.var_min)
.or(flag.var_min);
if let Some(min) = minimum {
fields.push(format!("VarMin: {}", clamp_var_max(min)));
}
if let Some(max) = flag.var_max {
fields.push(format!("VarMax: {}", clamp_var_max(max)));
}
for (label, names) in [
("Conflicts", &flag.conflicts),
("Overrides", &flag.overrides),
("RequiredUnless", &flag.required_unless),
("RequiredUnlessAll", &flag.required_unless_all),
("RequiredIf", &flag.required_if),
("Requires", &flag.requires),
] {
let keys = resolve_relationship(names, owner, commands);
if !keys.is_empty() {
fields.push(format!("{label}: {}", key_slice(&keys)));
}
}
for (label, conditions) in [
("RequiredIfEq", &flag.required_if_eq),
("RequiredIfEqAll", &flag.required_if_eq_all),
] {
let values = conditions
.iter()
.filter_map(|condition| {
resolve_relationship(std::slice::from_ref(&condition.selector), owner, commands)
.into_iter()
.next()
.map(|key| {
format!("{{Key: {key}, Value: {}}}", go_string(&condition.value))
})
})
.collect::<Vec<_>>();
if !values.is_empty() {
fields.push(format!(
"{label}: []argv.ValueCondition{{{}}}",
values.join(", ")
));
}
}
let requires_if = flag
.requires_if
.iter()
.filter_map(|condition| {
resolve_relationship(std::slice::from_ref(&condition.requires), owner, commands)
.into_iter()
.next()
.map(|key| format!("{{Value: {}, Key: {key}}}", go_string(&condition.value)))
})
.collect::<Vec<_>>();
if !requires_if.is_empty() {
fields.push(format!(
"RequiresIf: []argv.ValueRequirement{{{}}}",
requires_if.join(", ")
));
}
let default_if = flag
.default_if
.iter()
.filter_map(|condition| {
resolve_relationship(std::slice::from_ref(&condition.selector), owner, commands)
.into_iter()
.next()
.map(|key| match &condition.when {
None => format!("{{Key: {key}, Value: {}}}", go_string(&condition.value)),
Some(when) => format!(
"{{Key: {key}, When: {}, Value: {}}}",
go_string(when),
go_string(&condition.value)
),
})
})
.collect::<Vec<_>>();
if !default_if.is_empty() {
fields.push(format!(
"DefaultIf: []argv.DefaultIf{{{}}}",
default_if.join(", ")
));
}
format!("{{{}}}", fields.join(", "))
}
}
fn complete_type<'a>(spec: &'a Spec, name: &str) -> Option<&'a str> {
spec.complete
.get(&name.to_lowercase())
.and_then(|c| c.type_.as_deref())
}
fn arg_meta(
spec: &Spec,
arg: &SpecArg,
named: &Named,
owner: &Emitted,
commands: &[Emitted],
) -> String {
let mut fields = vec![
format!("Key: {}", named.key),
format!("Name: {}", go_string(&arg.name)),
];
if arg.required {
fields.push("Required: true".to_string());
}
if let Some(kind) = complete_type(spec, &arg.name) {
fields.push(format!("CompleteType: {}", go_string(kind)));
}
if let Some(choices) = &arg.choices {
fields.push(format!(
"Choices: {}",
string_slice(&visible_choices(choices))
));
fields.push(format!(
"AcceptedChoices: {}",
string_slice(&accepted_choices(choices))
));
if choices.ignore_case {
fields.push("IgnoreCase: true".to_string());
}
if !choices.strict {
fields.push("AllowUnknownChoices: true".to_string());
}
}
if !arg.default.is_empty() {
fields.push(format!("Default: {}", string_slice(&arg.default)));
}
if let Some(env) = &arg.env {
fields.push(format!("Env: {}", go_string(env)));
}
if !arg.env_fallback.is_empty() {
fields.push(format!("EnvFallback: {}", string_slice(&arg.env_fallback)));
}
if !arg.deprecated_env.is_empty() {
fields.push(format!(
"DeprecatedEnv: {}",
string_slice(&arg.deprecated_env)
));
}
if let Some(min) = arg.var_min {
fields.push(format!("VarMin: {}", clamp_var_max(min)));
}
let conflicts = resolve_relationship(&arg.conflicts, owner, commands);
if !conflicts.is_empty() {
fields.push(format!("Conflicts: {}", key_slice(&conflicts)));
}
for (label, names) in [
("Requires", &arg.requires),
("RequiredIf", &arg.required_if),
("RequiredUnless", &arg.required_unless),
("RequiredUnlessAll", &arg.required_unless_all),
] {
let keys = resolve_relationship(names, owner, commands);
if !keys.is_empty() {
fields.push(format!("{label}: {}", key_slice(&keys)));
}
}
for (label, conditions) in [
("RequiredIfEq", &arg.required_if_eq),
("RequiredIfEqAll", &arg.required_if_eq_all),
] {
let values = conditions
.iter()
.filter_map(|condition| {
resolve_relationship(std::slice::from_ref(&condition.selector), owner, commands)
.into_iter()
.next()
.map(|key| format!("{{Key: {key}, Value: {}}}", go_string(&condition.value)))
})
.collect::<Vec<_>>();
if !values.is_empty() {
fields.push(format!(
"{label}: []argv.ValueCondition{{{}}}",
values.join(", ")
));
}
}
format!("{{{}}}", fields.join(", "))
}
fn resolve_relationship(names: &[String], owner: &Emitted, commands: &[Emitted]) -> Vec<String> {
let mut out = Vec::new();
for name in names {
let mut found = match_flag(owner, name, false);
if found.is_none() && !name.starts_with('-') {
found = owner
.args
.iter()
.find(|(arg, _)| arg.name == *name)
.map(|(_, named)| named.key.clone());
}
if found.is_none() {
let path = &owner.cmd.full_cmd;
for depth in (0..path.len()).rev() {
let ancestor = commands
.iter()
.find(|e| e.cmd.full_cmd.len() == depth && e.cmd.full_cmd[..] == path[..depth]);
if let Some(key) = ancestor.and_then(|a| match_flag(a, name, true)) {
found = Some(key);
break;
}
}
}
if let Some(key) = found {
out.push(key);
}
}
out
}
fn match_flag(cmd: &Emitted, name: &str, globals_only: bool) -> Option<String> {
let eligible = |flag: &SpecFlag| !globals_only || flag.global;
let (long, short, bare) = if let Some(rest) = name.strip_prefix("--") {
(Some(rest), None, None)
} else if let Some(rest) = name.strip_prefix('-') {
let mut chars = rest.chars();
match (chars.next(), chars.next()) {
(Some(c), None) => (None, Some(c), None),
_ => (None, None, None),
}
} else {
(None, None, Some(name))
};
let ordinary = cmd.flags.iter().find(|(flag, _)| {
if !eligible(flag) {
return false;
}
if let Some(bare) = bare {
return flag.name == bare;
}
if let Some(long) = long {
return flag.long.iter().any(|l| l == long);
}
short.is_some_and(|c| flag.short.contains(&c))
});
if let Some((_, named)) = ordinary {
return Some(named.key.clone());
}
cmd.flags
.iter()
.find(|(flag, _)| eligible(flag) && flag.negate.as_deref() == Some(name))
.map(|(_, named)| named.key.clone())
}
fn string_slice(values: &[String]) -> String {
let list = values
.iter()
.map(|v| go_string(v))
.collect::<Vec<_>>()
.join(", ");
format!("[]string{{{list}}}")
}
fn accepted_choices(choices: &SpecChoices) -> Vec<String> {
choices
.choices
.iter()
.chain(
choices
.details
.iter()
.flat_map(|choice| choice.aliases.iter().map(|alias| &alias.value)),
)
.cloned()
.collect()
}
fn visible_choices(choices: &SpecChoices) -> Vec<String> {
choices
.choices
.iter()
.filter(|value| {
!choices
.details
.iter()
.any(|choice| choice.value == value.as_str() && choice.hide)
})
.chain(choices.details.iter().flat_map(|choice| {
choice
.aliases
.iter()
.filter(|alias| !alias.hide)
.map(|alias| &alias.value)
}))
.cloned()
.collect()
}
fn key_slice(keys: &[String]) -> String {
format!("[]uint64{{{}}}", keys.join(", "))
}
impl Emitter<'_> {
fn help_table(&mut self, commands: &[Emitted]) {
let mut by_key: BTreeMap<u64, String> = BTreeMap::new();
for e in commands {
by_key.insert(e.named.number, command_help(e));
for (flag, named) in &e.flags {
by_key.insert(named.number, flag_help(flag, named));
}
for (arg, named) in &e.args {
by_key.insert(named.number, arg_help(arg, named));
}
}
let total = commands
.iter()
.map(|e| 1 + e.flags.len() + e.args.len())
.sum::<usize>() as u64;
let _ = writeln!(
self.out,
"// HelpText is the third table, read only when a page is rendered. Neither the\n\
// parser nor the post-binding rules touch it, and a CLI that never prints help\n\
// does not carry it: Go's linker drops an unreferenced table whole.\n\
//\n\
// Indexed by key, like the others.\n\
var HelpText = argv.HelpTable{{"
);
for key in 1..=total {
match by_key.get(&key) {
Some(entry) => {
let _ = writeln!(self.out, "\t{entry},");
}
None => {
let _ = writeln!(self.out, "\t{{}},");
}
}
}
let _ = writeln!(self.out, "}}\n");
let mut fields = vec![
format!("Name: {}", go_string(&self.spec.name)),
format!("Bin: {}", go_string(&self.spec.bin)),
];
if let Some(version) = self
.spec
.version
.as_ref()
.or(self.spec.long_version.as_ref())
{
fields.push(format!("Version: {}", go_string(version)));
}
if let Some(version) = &self.spec.long_version {
fields.push(format!("LongVersion: {}", go_string(version)));
}
if let Some(about) = &self.spec.about {
fields.push(format!("About: {}", go_string(about)));
}
if let Some(long) = &self.spec.about_long {
fields.push(format!("LongAbout: {}", go_string(long)));
}
if let Some(author) = &self.spec.author {
fields.push(format!("Author: {}", go_string(author)));
}
if let Some(license) = &self.spec.license {
fields.push(format!("License: {}", go_string(license)));
}
if let Some(before) = &self.spec.before_help {
fields.push(format!("BeforeHelp: {}", go_string(before)));
}
if let Some(after) = &self.spec.after_help {
fields.push(format!("AfterHelp: {}", go_string(after)));
}
if let Some(before) = &self.spec.before_help_long {
fields.push(format!("BeforeLongHelp: {}", go_string(before)));
}
if let Some(after) = &self.spec.after_help_long {
fields.push(format!("AfterLongHelp: {}", go_string(after)));
}
if let Some(template) = &self.spec.help_template {
fields.push(format!("HelpTemplate: {}", go_string(template)));
}
let _ = writeln!(
self.out,
"// HelpMeta is what a page needs from the spec's root rather than from any one\n\
// command: the header, and the text that brackets every page.\n\
var HelpMeta = argv.HelpSpec{{{}}}\n",
fields.join(", ")
);
}
}
fn command_help(e: &Emitted) -> String {
let mut fields = vec![format!("Key: {}", e.named.key)];
if e.cmd.hide {
fields.push("Hide: true".to_string());
}
if let Some(heading) = &e.cmd.help_heading {
fields.push(format!("Heading: {}", go_string(heading)));
}
if let Some(order) = e.cmd.display_order {
fields.push(format!("DisplayOrder: {order}"));
fields.push("DisplayOrderSet: true".to_string());
}
if let Some(help) = e.cmd.help.as_deref().or(e.cmd.help_long.as_deref()) {
fields.push(format!("Short: {}", go_string(help)));
}
if let Some(long) = &e.cmd.help_long {
fields.push(format!("Long: {}", go_string(long)));
}
if let Some(message) = &e.cmd.deprecated {
fields.push(format!("Deprecated: {}", go_string(message)));
}
if let Some(at) = &e.cmd.deprecated_warn_at {
fields.push(format!("DeprecatedWarnAt: {}", go_string(at)));
}
if let Some(at) = &e.cmd.deprecated_remove_at {
fields.push(format!("DeprecatedRemoveAt: {}", go_string(at)));
}
if let Some(heading) = &e.cmd.subcommand_help_heading {
fields.push(format!("SubcommandHelpHeading: {}", go_string(heading)));
}
if let Some(name) = &e.cmd.subcommand_value_name {
fields.push(format!("SubcommandValueName: {}", go_string(name)));
}
if e.cmd.next_line_help {
fields.push("NextLineHelp: true".to_string());
}
if e.cmd.flatten_help {
fields.push("FlattenHelp: true".to_string());
}
if e.cmd.subcommand_required {
fields.push("SubcommandRequired: true".to_string());
}
let visible: Vec<String> = e
.cmd
.aliases
.iter()
.filter(|a| !e.cmd.hidden_aliases.contains(a))
.cloned()
.collect();
if !visible.is_empty() {
fields.push(format!("VisibleAliases: {}", string_slice(&visible)));
}
if let Some(before) = &e.cmd.before_help {
fields.push(format!("BeforeHelp: {}", go_string(before)));
}
if let Some(after) = &e.cmd.after_help {
fields.push(format!("AfterHelp: {}", go_string(after)));
}
if let Some(before) = &e.cmd.before_help_long {
fields.push(format!("BeforeLongHelp: {}", go_string(before)));
}
if let Some(after) = &e.cmd.after_help_long {
fields.push(format!("AfterLongHelp: {}", go_string(after)));
}
if !e.cmd.examples.is_empty() {
let items = e
.cmd
.examples
.iter()
.map(|x| {
let mut parts = Vec::new();
if let Some(header) = &x.header {
parts.push(format!("Header: {}", go_string(header)));
}
parts.push(format!("Code: {}", go_string(&x.code)));
if let Some(help) = &x.help {
parts.push(format!("Help: {}", go_string(help)));
}
format!("{{{}}}", parts.join(", "))
})
.collect::<Vec<_>>()
.join(", ");
fields.push(format!("Examples: []argv.Example{{{items}}}"));
}
format!("{{{}}}", fields.join(", "))
}
fn flag_help(flag: &SpecFlag, named: &Named) -> String {
let mut fields = vec![format!("Key: {}", named.key)];
if let Some(message) = &flag.deprecated {
fields.push(format!("Deprecated: {}", go_string(message)));
}
if let Some(at) = &flag.deprecated_warn_at {
fields.push(format!("DeprecatedWarnAt: {}", go_string(at)));
}
if let Some(at) = &flag.deprecated_remove_at {
fields.push(format!("DeprecatedRemoveAt: {}", go_string(at)));
}
if flag.hide {
fields.push("Hide: true".to_string());
}
if let Some(order) = flag.display_order {
fields.push(format!("DisplayOrder: {order}"));
fields.push("DisplayOrderSet: true".to_string());
}
for (name, hidden) in [
("HideDefaultValue", flag.hide_default_value),
("HideEnv", flag.hide_env),
("HideEnvValues", flag.hide_env_values),
("HidePossibleValues", flag.hide_possible_values),
("HideShortHelp", flag.hide_short_help),
("HideLongHelp", flag.hide_long_help),
] {
if hidden {
fields.push(format!("{name}: true"));
}
}
if flag.required && flag.default.is_empty() {
fields.push("Demanded: true".to_string());
}
if flag.var {
fields.push("Repeatable: true".to_string());
}
if let Some(arg) = &flag.arg {
if arg.name != flag.name {
fields.push(format!("ValueName: {}", go_string(&arg.name)));
}
if arg.required && arg.default.is_empty() {
fields.push("ValueDemanded: true".to_string());
}
if !arg.value_names.is_empty() {
fields.push(format!("ValueNames: {}", string_slice(&arg.value_names)));
}
if arg.var && arg.var_min == arg.var_max && arg.var_min.is_some_and(|n| n > 1) {
fields.push(format!("ValueArity: {}", arg.var_min.unwrap()));
}
}
if let Some(help) = flag.help.as_deref().or(flag.help_first_line.as_deref()) {
fields.push(format!("Short: {}", go_string(help)));
}
if let Some(long) = flag.help_long.as_deref().or(flag.help.as_deref()) {
fields.push(format!("Long: {}", go_string(long)));
}
if let Some(heading) = &flag.help_heading {
fields.push(format!("Heading: {}", go_string(heading)));
}
if let Some(choices) = flag.arg.as_ref().and_then(|a| a.choices.as_ref()) {
fields.push(format!(
"Choices: {}",
string_slice(&visible_choices(choices))
));
}
if let Some(env) = &flag.env {
fields.push(format!("Env: {}", go_string(env)));
}
if !flag.env_fallback.is_empty() {
fields.push(format!("EnvFallback: {}", string_slice(&flag.env_fallback)));
}
if !flag.deprecated_env.is_empty() {
fields.push(format!(
"DeprecatedEnv: {}",
string_slice(&flag.deprecated_env)
));
}
let default = if !flag.default.is_empty() {
&flag.default
} else {
flag.arg
.as_ref()
.map(|a| &a.default)
.unwrap_or(&flag.default)
};
if !default.is_empty() {
fields.push(format!("Default: {}", string_slice(default)));
}
format!("{{{}}}", fields.join(", "))
}
fn arg_help(arg: &SpecArg, named: &Named) -> String {
let mut fields = vec![format!("Key: {}", named.key)];
if let Some(order) = arg.display_order {
fields.push(format!("DisplayOrder: {order}"));
fields.push("DisplayOrderSet: true".to_string());
}
if arg.hide {
fields.push("Hide: true".to_string());
}
for (name, hidden) in [
("HideDefaultValue", arg.hide_default_value),
("HideEnv", arg.hide_env),
("HideEnvValues", arg.hide_env_values),
("HidePossibleValues", arg.hide_possible_values),
("HideShortHelp", arg.hide_short_help),
("HideLongHelp", arg.hide_long_help),
] {
if hidden {
fields.push(format!("{name}: true"));
}
}
if arg.required && arg.default.is_empty() {
fields.push("Demanded: true".to_string());
}
if !arg.value_names.is_empty() {
fields.push(format!("ValueNames: {}", string_slice(&arg.value_names)));
}
if arg.var && arg.var_min == arg.var_max && arg.var_min.is_some_and(|n| n > 1) {
fields.push(format!("ValueArity: {}", arg.var_min.unwrap()));
}
if let Some(help) = arg.help.as_deref().or(arg.help_first_line.as_deref()) {
fields.push(format!("Short: {}", go_string(help)));
}
if let Some(long) = arg.help_long.as_deref().or(arg.help.as_deref()) {
fields.push(format!("Long: {}", go_string(long)));
}
if let Some(heading) = &arg.help_heading {
fields.push(format!("Heading: {}", go_string(heading)));
}
if let Some(choices) = &arg.choices {
fields.push(format!(
"Choices: {}",
string_slice(&visible_choices(choices))
));
}
if let Some(env) = &arg.env {
fields.push(format!("Env: {}", go_string(env)));
}
if !arg.env_fallback.is_empty() {
fields.push(format!("EnvFallback: {}", string_slice(&arg.env_fallback)));
}
if !arg.deprecated_env.is_empty() {
fields.push(format!(
"DeprecatedEnv: {}",
string_slice(&arg.deprecated_env)
));
}
if !arg.default.is_empty() {
fields.push(format!("Default: {}", string_slice(&arg.default)));
}
format!("{{{}}}", fields.join(", "))
}
enum Line {
Field(String, String),
Block(Vec<String>),
}
fn render(out: &mut String, indent: &str, lines: &[Line]) {
let mut run: Vec<(&String, &String)> = Vec::new();
fn flush(out: &mut String, indent: &str, run: &mut Vec<(&String, &String)>) {
let width = run.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
for (key, value) in run.iter() {
let _ = writeln!(
out,
"{indent}{key}:{:width$} {value},",
"",
width = width - key.len()
);
}
run.clear();
}
for line in lines {
match line {
Line::Field(key, value) => run.push((key, value)),
Line::Block(block) => {
flush(out, indent, &mut run);
for l in block {
let _ = writeln!(out, "{indent}{l}");
}
}
}
}
flush(out, indent, &mut run);
}
struct Emitted {
named: Named,
cmd: SpecCommand,
flags: Vec<(SpecFlag, Named)>,
args: Vec<(SpecArg, Named)>,
subcommands: Vec<usize>,
root: bool,
}
fn effective_unknown_flags(spec: &Spec, commands: &[Emitted], at: usize) -> UnknownFlags {
let path = &commands[at].cmd.full_cmd;
for depth in (0..=path.len()).rev() {
let ancestor = commands
.iter()
.find(|e| e.cmd.full_cmd.len() == depth && e.cmd.full_cmd[..] == path[..depth]);
if let Some(mode) = ancestor.and_then(|e| e.cmd.unknown_flags) {
return mode;
}
}
spec.unknown_flags.unwrap_or_default()
}
fn flag_literal(flag: &SpecFlag, named: &Named) -> String {
let mut fields = vec![
format!("Key: {}", named.key),
format!("Name: {}", go_string(&flag.name)),
];
if !flag.long.is_empty() {
let longs = flag
.long
.iter()
.map(|l| go_string(l))
.collect::<Vec<_>>()
.join(", ");
fields.push(format!("Longs: []string{{{longs}}}"));
}
if !flag.hidden_aliases.is_empty() {
fields.push(format!(
"HiddenLongs: {}",
string_slice(&flag.hidden_aliases)
));
}
if !flag.short.is_empty() {
let shorts = flag
.short
.iter()
.map(|c| go_byte(*c))
.collect::<Vec<_>>()
.join(", ");
fields.push(format!("Shorts: []byte{{{shorts}}}"));
}
if !flag.hidden_short_aliases.is_empty() {
let shorts = flag
.hidden_short_aliases
.iter()
.map(|c| go_byte(*c))
.collect::<Vec<_>>()
.join(", ");
fields.push(format!("HiddenShorts: []byte{{{shorts}}}"));
}
if let Some(negate) = &flag.negate {
fields.push(format!(
"Negate: {}",
go_string(negate.trim_start_matches('-'))
));
}
if flag.arg.is_some() {
fields.push("TakesValue: true".to_string());
}
if flag.value_optional {
fields.push("ValueOptional: true".to_string());
}
if flag.bool_value {
fields.push("BoolValue: true".to_string());
}
let action = match flag.action {
SpecFlagAction::Set => None,
SpecFlagAction::Help => Some("argv.ActionHelp"),
SpecFlagAction::HelpShort => Some("argv.ActionHelpShort"),
SpecFlagAction::HelpLong => Some("argv.ActionHelpLong"),
SpecFlagAction::HelpAll => Some("argv.ActionHelpAll"),
SpecFlagAction::Version => Some("argv.ActionVersion"),
};
if let Some(action) = action {
fields.push(format!("Action: {action}"));
}
if let Some(arg) = flag.arg.as_ref().filter(|a| a.var) {
fields.push("Variadic: true".to_string());
if let Some(max) = arg.var_max {
fields.push(format!("VarMax: {}", clamp_var_max(max)));
}
}
if flag.allow_hyphen_values() {
fields.push("AllowHyphenValues: true".to_string());
}
if let Some(arg) = &flag.arg {
if arg.allow_negative_numbers {
fields.push("AllowNegativeNumbers: true".to_string());
}
if let Some(terminator) = &arg.value_terminator {
fields.push(format!("ValueTerminator: {}", go_string(terminator)));
}
if let Some(delimiter) = arg.delimiter {
fields.push(format!("Delimiter: {}", go_byte(delimiter)));
}
}
if flag.require_equals {
fields.push("RequireEquals: true".to_string());
}
if let Some(missing) = &flag.default_missing {
fields.push(format!("DefaultMissing: {}", go_string(missing)));
}
if flag.global {
fields.push("Global: true".to_string());
}
format!("{{{}}}", fields.join(", "))
}
fn arg_literal(arg: &SpecArg, named: &Named) -> String {
let mut fields = vec![
format!("Key: {}", named.key),
format!("Name: {}", go_string(&arg.name)),
];
if arg.required {
fields.push("Required: true".to_string());
}
if arg.var {
fields.push("Var: true".to_string());
if let Some(max) = arg.var_max {
fields.push(format!("VarMax: {}", clamp_var_max(max)));
}
}
if arg.allow_negative_numbers {
fields.push("AllowNegativeNumbers: true".to_string());
}
if let Some(terminator) = &arg.value_terminator {
fields.push(format!("ValueTerminator: {}", go_string(terminator)));
}
if let Some(delimiter) = arg.delimiter {
fields.push(format!("Delimiter: {}", go_byte(delimiter)));
}
let double_dash = match arg.double_dash {
SpecDoubleDashChoices::Required => Some("argv.DoubleDashRequired"),
SpecDoubleDashChoices::Preserve => Some("argv.DoubleDashPreserve"),
SpecDoubleDashChoices::Automatic => Some("argv.DoubleDashAutomatic"),
_ => None,
};
if let Some(dd) = double_dash {
fields.push(format!("DoubleDash: {dd}"));
}
format!("{{{}}}", fields.join(", "))
}
fn clamp_var_max(max: usize) -> u32 {
u32::try_from(max).unwrap_or(u32::MAX)
}
const GO_KEYWORDS: &[&str] = &[
"break",
"case",
"chan",
"const",
"continue",
"default",
"defer",
"else",
"fallthrough",
"for",
"func",
"go",
"goto",
"if",
"import",
"interface",
"map",
"package",
"range",
"return",
"select",
"struct",
"switch",
"type",
"var",
];
const UNUSABLE_PACKAGE_NAMES: &[&str] = &["_", "init"];
pub fn is_valid_package(name: &str) -> bool {
!name.is_empty()
&& !GO_KEYWORDS.contains(&name)
&& !UNUSABLE_PACKAGE_NAMES.contains(&name)
&& !name.starts_with(|c: char| c.is_ascii_digit())
&& name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
}
fn field_name(name: &str) -> String {
let ident = format!("{}", AsPascalCase(name));
if ident.is_empty() || ident.starts_with(|c: char| c.is_ascii_digit()) {
format!("X{ident}")
} else {
ident
}
}
fn package_ident(bin: &str) -> String {
let lowered: String = bin
.chars()
.filter(|c| c.is_ascii_alphanumeric() || *c == '_')
.collect::<String>()
.to_ascii_lowercase();
if is_valid_package(&lowered) {
lowered
} else {
format!("cli{lowered}")
}
}
fn go_string(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('"');
for c in s.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if (c as u32) < 0x20 || c as u32 == 0x7f => {
let _ = write!(out, "\\x{:02x}", c as u32);
}
c => out.push(c),
}
}
out.push('"');
out
}
fn go_byte(c: char) -> String {
match c {
'\'' => "'\\''".to_string(),
'\\' => "'\\\\'".to_string(),
c if c.is_ascii_graphic() => format!("'{c}'"),
c => format!("0x{:02x}", (c as u32) & 0xff),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn entry_of(out: &str, name: &str) -> String {
out.lines()
.find(|l| l.contains(&format!("Name: \"{name}\", Flag: true")))
.unwrap_or_default()
.to_string()
}
fn go(kdl: &str) -> String {
let spec: Spec = kdl.parse().expect("the fixture spec should parse");
generate(&spec, &GoOptions::default())
}
#[test]
fn a_whole_cli() {
let out = go(r#"
name "ex"
bin "ex"
version "1.2.3"
long_version "1.2.3\ncommit abc123"
flag "-v --verbose" global=#true help="be loud"
flag "--color" negate="--no-color"
flag "-j --jobs <n>"
flag "--include <pattern>..." var_max=3
arg "<file>"
arg "[rest]..." var=#true
cmd "install" {
alias "i"
flag "-f --force"
arg "<pkg>"
}
cmd "config" {
cmd "ls" {
flag "--no-header"
}
}
"#);
insta::assert_snapshot!(out);
}
#[test]
fn rich_choices_keep_acceptance_visibility_and_strictness_separate() {
let out = go(r#"
name "ex"
bin "ex"
flag "--color <when>" {
choices ignore_case=#true strict=#false {
choice "always" {
alias "yes"
alias "on" hide=#true
}
choice "never" hide=#true
}
}
"#);
let entry = entry_of(&out, "color");
assert!(
entry.contains(r#"Choices: []string{"always", "yes"}"#),
"{entry}"
);
assert!(
entry.contains(r#"AcceptedChoices: []string{"always", "never", "yes", "on"}"#),
"{entry}"
);
assert!(entry.contains("IgnoreCase: true"), "{entry}");
assert!(entry.contains("AllowUnknownChoices: true"), "{entry}");
}
#[test]
fn unknown_flags_are_inherited_and_overridable() {
let out = go(r#"
name "ex"
bin "ex"
unknown_flags "error"
cmd "strict" {
cmd "deep" {}
}
cmd "exec" unknown_flags="value" {
cmd "nested" {}
}
"#);
insta::assert_snapshot!(out);
}
#[test]
fn colliding_names_get_distinct_identifiers() {
let out = go(r#"
name "ex"
bin "ex"
cmd "macos-defaults" {
flag "--apply"
}
cmd "macos" {
cmd "defaults" {
flag "--apply"
}
}
"#);
insta::assert_snapshot!(out);
}
#[test]
fn a_default_subcommand_points_into_the_tree() {
let out = go(r#"
name "ex"
bin "ex"
default_subcommand "run"
arg "[task]"
cmd "run" {
arg "[args]..." var=#true
}
"#);
insta::assert_snapshot!(out);
}
#[test]
fn a_bin_name_that_is_not_an_identifier_still_gives_a_package() {
assert_eq!(package_ident("my-cli"), "mycli");
assert_eq!(package_ident("7zip"), "cli7zip");
assert_eq!(package_ident(""), "cli");
assert_eq!(package_ident("go"), "cligo");
assert_eq!(package_ident("type"), "clitype");
assert_eq!(package_ident("_"), "cli_");
assert_eq!(package_ident("init"), "cliinit");
assert_eq!(package_ident("__"), "__");
assert_eq!(package_ident("initialize"), "initialize");
for bin in [
"my-cli", "7zip", "", "go", "type", "_", "init", "__", "MiSe",
] {
let out = package_ident(bin);
assert!(is_valid_package(&out), "{bin:?} sanitized to {out:?}");
}
}
#[test]
fn a_name_matching_a_generated_suffix_still_gets_its_own() {
let out = go(r#"
name "ex"
bin "ex"
cmd "macos-defaults" {}
cmd "macos" {
cmd "defaults" {}
}
cmd "macos-defaults2" {}
"#);
assert_declares_each_constant_once(&out);
}
fn constant_names(out: &str) -> Vec<&str> {
out.lines()
.skip_while(|l| !l.starts_with("const ("))
.skip(1)
.take_while(|l| !l.starts_with(')'))
.filter_map(|l| l.split_whitespace().next())
.collect()
}
fn assert_declares_each_constant_once(out: &str) {
let names = constant_names(out);
assert!(!names.is_empty(), "no constants at all:\n{out}");
let mut seen = std::collections::HashSet::new();
for name in &names {
assert!(seen.insert(*name), "{name} is declared twice:\n{out}");
}
}
#[test]
fn a_package_that_would_not_compile_is_refused_rather_than_emitted() {
assert!(is_valid_package("mycli"));
assert!(is_valid_package("mise_tables"));
assert!(!is_valid_package("my-pkg"));
assert!(!is_valid_package("7zip"));
assert!(!is_valid_package(""));
assert!(!is_valid_package("range"));
assert!(!is_valid_package("_"));
assert!(!is_valid_package("init"));
assert!(is_valid_package("__"));
let spec: Spec = "name \"ex\"\nbin \"ex\"\n".parse().unwrap();
let out = generate(
&spec,
&GoOptions {
package: Some("my-pkg".into()),
},
);
assert!(out.contains("package mypkg"), "{out}");
}
#[test]
fn the_long_pages_text_reaches_the_tables() {
let out = go(r#"
name "ex"
bin "ex"
about "Short."
about_long "Long."
before_long_help "ROOT-BEFORE"
after_long_help "ROOT-AFTER"
cmd "run" help="Run it" {
before_long_help "RUN-BEFORE"
after_long_help "RUN-AFTER"
}
"#);
let meta = out
.lines()
.find(|l| l.contains("var HelpMeta"))
.expect("a root header is emitted");
assert!(
meta.contains(r#"About: "Short.""#) && meta.contains(r#"LongAbout: "Long.""#),
"the two abouts are separate fields: {meta}"
);
assert!(
meta.contains(r#"BeforeLongHelp: "ROOT-BEFORE""#)
&& meta.contains(r#"AfterLongHelp: "ROOT-AFTER""#),
"the root's long brackets are emitted: {meta}"
);
let run = out
.lines()
.find(|l| l.contains("Short: \"Run it\""))
.expect("the command has a help entry");
assert!(
run.contains(r#"BeforeLongHelp: "RUN-BEFORE""#)
&& run.contains(r#"AfterLongHelp: "RUN-AFTER""#),
"a command's long brackets are emitted: {run}"
);
}
#[test]
fn an_examples_help_line_reaches_the_tables() {
let out = go(r#"
name "ex"
bin "ex"
cmd "run" help="Run it" {
example "ex run --fast" header="Speed" help="When you are in a hurry"
example "ex run"
}
"#);
let run = out
.lines()
.find(|l| l.contains("Examples: []argv.Example"))
.expect("the command's examples are emitted");
assert!(
run.contains(
r#"{Header: "Speed", Code: "ex run --fast", Help: "When you are in a hurry"}"#
),
"all three fields are emitted: {run}"
);
assert!(
run.contains(r#"{Code: "ex run"}"#),
"an example with no header emits no header: {run}"
);
}
#[test]
fn a_long_about_alone_does_not_become_the_short_one() {
let out = go(r#"
name "ex"
bin "ex"
about_long "Long only."
"#);
let meta = out
.lines()
.find(|l| l.contains("var HelpMeta"))
.expect("a root header is emitted");
assert!(
!meta.contains(", About: ") && meta.contains(r#"LongAbout: "Long only.""#),
"only the long one is set: {meta}"
);
}
#[test]
fn a_default_subcommand_ignores_a_deeper_command_of_the_same_name() {
let out = go(r#"
name "ex"
bin "ex"
default_subcommand "run"
cmd "oci" {
cmd "run" {}
}
cmd "run" {
arg "[args]..." var=#true
}
"#);
assert!(
out.contains("DefaultSubcommand: cmdRun,"),
"should point at the root's own `run`, got:\n{out}"
);
}
#[test]
fn command_builtin_controls_reach_generated_go_tables() {
let out = go(r#"
name "ex"
bin "ex"
disable_help_flag #true
disable_help_subcommand #true
disable_version_flag #true
"#);
let root = out
.split("var Root = &argv.Command{")
.nth(1)
.expect("the root command should be emitted")
.split("}\n")
.next()
.unwrap();
assert!(root.contains("DisableHelpFlag:"), "{root}");
assert!(root.contains("DisableHelpSubcommand:"), "{root}");
assert!(root.contains("DisableVersionFlag:"), "{root}");
}
#[test]
fn a_default_subcommand_prefers_a_name_to_another_commands_alias() {
let ordered = |first: &str, second: &str| {
go(&format!(
r#"
name "ex"
bin "ex"
default_subcommand "run"
{first}
{second}
"#
))
};
let alpha = "cmd \"alpha\" {\n alias \"run\"\n}";
let run = "cmd \"run\" {\n arg \"[args]...\" var=#true\n}";
for out in [ordered(alpha, run), ordered(run, alpha)] {
assert!(
out.contains("DefaultSubcommand: cmdRun,"),
"should point at the command named `run`, got:\n{out}"
);
}
}
#[test]
fn an_external_subcommand_is_emitted_on_the_command_that_declares_it() {
let out = go(r#"
name "ex"
bin "ex"
external_subcommand #true
cmd "install"
cmd "exec" external_subcommand=#true
"#);
let block = |var: &str| {
let start = out
.find(&format!("var {var} ="))
.unwrap_or_else(|| panic!("{var} should be emitted, got:\n{out}"));
let rest = &out[start..];
let end = rest[1..]
.find("\nvar ")
.map(|i| i + 1)
.unwrap_or(rest.len());
&rest[..end]
};
assert!(
block("Root").contains("ExternalSubcommand: true"),
"the root should forward unmatched words:\n{}",
block("Root")
);
assert!(
block("cmdExec").contains("ExternalSubcommand: true"),
"a nested command can forward too:\n{}",
block("cmdExec")
);
assert!(
!block("cmdInstall").contains("ExternalSubcommand"),
"a command that does not declare it should not carry it:\n{}",
block("cmdInstall")
);
}
#[test]
fn arg_required_else_help_reaches_the_table_and_typed_front_door() {
let out = go(r#"
name "ex"
bin "ex"
cmd "run" arg_required_else_help=#true {
flag "--all"
}
"#);
assert!(
out.contains("ArgRequiredElseHelp: true"),
"the command table should carry the policy:\n{out}"
);
assert!(
out.contains("p.Command().ArgRequiredElseHelp && p.CommandStart() == len(args)"),
"the typed parser should enforce it before fallbacks:\n{out}"
);
}
#[test]
fn subcommand_negates_requirements_reaches_generated_go() {
let out = go(
"name \"ex\"\nbin \"ex\"\nsubcommand_negates_reqs #true\nflag \"--config\" required=#true\ncmd \"run\"\n",
);
assert!(out.contains("SubcommandNegatesReqs: true"), "{out}");
assert!(
out.contains("checkRequirements := i == len(chain)-1 || !cmd.SubcommandNegatesReqs"),
"{out}"
);
assert!(
out.contains("CheckRelationshipsWithValuesAndRequirements"),
"{out}"
);
}
#[test]
fn argument_subcommand_conflicts_reach_generated_go() {
let out = go(
"name \"ex\"\nbin \"ex\"\nargs_conflicts_with_subcommands #true\nflag \"--verbose\"\ncmd \"run\"\n",
);
assert!(out.contains("ArgsConflictWithSubcommands: true"), "{out}");
}
#[test]
fn allow_missing_positional_reaches_generated_go() {
let out = go(
"name \"ex\"\nbin \"ex\"\nallow_missing_positional #true\narg \"[optional]\"\narg \"<required>\"\n",
);
assert!(out.contains("AllowMissingPositional: true"), "{out}");
assert!(out.contains("Name: \"optional\""), "{out}");
assert!(out.contains("Name: \"required\", Required: true"), "{out}");
}
#[test]
fn optional_flag_values_reach_generated_go() {
let out = go("name \"ex\"\nbin \"ex\"\nflag \"--color [WHEN]\" value_optional=#true\n");
assert!(
out.contains("TakesValue: true, ValueOptional: true"),
"{out}"
);
}
#[test]
fn explicit_boolean_values_reach_generated_go() {
let out = go(
"name \"ex\"\nbin \"ex\"\nflag \"--color\" negate=\"--no-color\" bool_value=#true\n",
);
assert!(out.contains("BoolValue: true"), "{out}");
assert!(out.contains("if ev.Flag.BoolValue"), "{out}");
assert!(
out.contains("given[ev.Flag.Key] = []string{ev.Value}"),
"{out}"
);
assert!(
out.contains("(ev.Value == \"true\") != ev.Negated"),
"{out}"
);
}
#[test]
fn flag_actions_reach_generated_go() {
let out = go(
"name \"ex\"\nbin \"ex\"\nflag \"--help-all\" action=\"help_all\"\nflag \"--version\" action=\"version\"\n",
);
assert!(out.contains("Action: argv.ActionHelpAll"), "{out}");
assert!(out.contains("Action: argv.ActionVersion"), "{out}");
}
#[test]
fn granular_help_hides_reach_generated_go() {
let out = go(
"name \"ex\"\nbin \"ex\"\nflag \"--mode <mode>\" hide_default_value=#true hide_env=#true hide_env_values=#true hide_possible_values=#true hide_short_help=#true hide_long_help=#true\n",
);
for field in [
"HideDefaultValue: true",
"HideEnv: true",
"HideEnvValues: true",
"HidePossibleValues: true",
"HideShortHelp: true",
"HideLongHelp: true",
] {
assert!(out.contains(field), "missing {field}:\n{out}");
}
}
#[test]
fn strict_duplicate_policy_reaches_metadata() {
let permissive = go("name \"ex\"\nbin \"ex\"\nflag \"--jobs <n>\"\n");
assert!(!permissive.contains("RejectDuplicate"), "{permissive}");
let strict =
go("name \"ex\"\nbin \"ex\"\nargs_override_self #false\nflag \"--jobs <n>\"\n");
assert!(strict.contains("RejectDuplicate: true"), "{strict}");
}
#[test]
fn strict_negated_flags_track_each_spelling_separately() {
let out = go(
"name \"ex\"\nbin \"ex\"\nargs_override_self #false\nflag \"--color\" negate=\"--no-color\"\n",
);
assert!(out.contains("polaritySeen := map[uint64]uint8{}"), "{out}");
assert!(
out.contains("polaritySeen[ev.Flag.Key]&polarity != 0"),
"{out}"
);
assert!(out.contains("if duplicateSeen[key]"), "{out}");
}
#[test]
fn strict_global_duplicate_tracking_resets_at_subcommands() {
let out = go(
"name \"ex\"\nbin \"ex\"\nargs_override_self #false\nflag \"--jobs <n>\" global=#true\ncmd \"run\" {\n args_override_self #false\n}\n",
);
assert!(
out.contains("levelSeen = map[uint64]int{}"),
"a subcommand should start a new duplicate scope:\n{out}"
);
assert!(out.contains("strictSeen[ev.Flag.Key] = true"), "{out}");
assert!(out.contains("if strictSeen[key]"), "{out}");
}
#[test]
fn a_subcommand_named_root_does_not_collide_with_the_root() {
let out = go(r#"
name "ex"
bin "ex"
cmd "root" {
flag "--wat"
}
"#);
let declared = |name: &str| {
out.lines()
.filter(|l| l.split_whitespace().next() == Some(name))
.count()
};
assert_eq!(declared("CmdRoot"), 1, "CmdRoot declared twice:\n{out}");
assert_eq!(declared("CmdRoot2"), 1, "no distinct key for it:\n{out}");
assert_declares_each_constant_once(&out);
}
#[test]
fn only_the_per_occurrence_bound_reaches_the_table() {
let out = go(r#"
name "ex"
bin "ex"
flag "--include <pattern>..." {
arg "<pattern>..." var=#true var_min=2 var_max=2
}
flag "--tag <t>" var=#true var_max=1
"#);
assert!(
out.contains("Name: \"include\", Longs: []string{\"include\"}, TakesValue: true, Variadic: true, VarMax: 2"),
"{out}"
);
assert!(
out.contains("Name: \"include\", Flag: true") && out.contains("VarMin: 2"),
"the nested value minimum must reach post-binding metadata:\n{out}"
);
let tag = out.lines().find(|l| l.contains("\"tag\"")).unwrap();
assert!(!tag.contains("VarMax"), "occurrence bound leaked: {tag}");
}
#[test]
fn exact_arity_with_one_label_reaches_go_help() {
let out = go(r#"
name "ex"
bin "ex"
flag "--pair <ITEM>..." {
arg "<ITEM>..." var=#true var_min=2 var_max=2 {
value_names "ITEM"
}
}
arg "<ITEM>..." var=#true var_min=2 var_max=2 {
value_names "ITEM"
}
"#);
assert_eq!(out.matches("ValueArity: 2").count(), 2, "{out}");
assert_eq!(
out.matches("ValueNames: []string{\"ITEM\"}").count(),
2,
"{out}"
);
}
#[test]
fn allow_hyphen_values_reaches_the_table() {
let out = go(r#"
name "ex"
bin "ex"
flag "--args <ARGS>" allow_hyphen_values=#true
"#);
assert!(
out.contains("Name: \"args\", Longs: []string{\"args\"}, TakesValue: true, AllowHyphenValues: true"),
"{out}"
);
}
#[test]
fn require_equals_reaches_the_table() {
let out = go(r#"
name "ex"
bin "ex"
flag "--inspect <PORT>" require_equals=#true
"#);
assert!(
out.contains("Name: \"inspect\", Longs: []string{\"inspect\"}, TakesValue: true, RequireEquals: true"),
"{out}"
);
}
#[test]
fn default_missing_reaches_the_table() {
let out = go(r#"
name "ex"
bin "ex"
flag "--color <WHEN>" default_missing="always"
"#);
assert!(
out.contains(
"Name: \"color\", Longs: []string{\"color\"}, TakesValue: true, DefaultMissing: \"always\""
),
"{out}"
);
}
#[test]
fn a_relationship_resolves_through_scope_and_negation() {
let out = go(r#"
name "ex"
bin "ex"
flag "--quiet" global=#true
flag "--color" negate="--no-color"
flag "--plain" conflicts="--no-color"
cmd "run" {
flag "--loud" conflicts="--quiet"
flag "--solo" conflicts="--plain"
}
"#);
assert!(out.contains("Conflicts: []uint64{FlagColor}"), "{out}");
assert!(out.contains("Conflicts: []uint64{FlagQuiet}"), "{out}");
assert!(
!entry_of(&out, "solo").contains("Conflicts"),
"a non-global should not resolve from below:\n{out}"
);
}
#[test]
fn positional_conflicts_reach_go_metadata_in_both_directions() {
let out = go(r#"
name "ex"
bin "ex"
flag "--from-file <file>" conflicts="value"
arg "[value]" conflicts="--from-file"
"#);
assert!(
entry_of(&out, "from-file").contains("Conflicts: []uint64{ArgValue}"),
"{out}"
);
assert!(
out.lines().any(|line| {
line.contains("{Key: ArgValue, Name: \"value\"")
&& line.contains("Conflicts: []uint64{FlagFromFile}")
}),
"{out}"
);
}
#[test]
fn a_value_conditional_requirement_reaches_go_metadata() {
let out = go(r#"
name "ex"
bin "ex"
flag "--format <format>" {
requires_if "json" "--schema"
}
flag "--schema <file>"
"#);
assert!(
entry_of(&out, "format").contains(
"RequiresIf: []argv.ValueRequirement{{Value: \"json\", Key: FlagSchema}}"
),
"{out}"
);
assert!(
out.contains("argv.CheckRelationshipsWithValues"),
"the emitted parser must enforce the metadata:\n{out}"
);
}
#[test]
fn required_if_eq_makes_generated_go_supply_values() {
let out = go(r#"
name "ex"
bin "ex"
flag "--token <token>" {
required_if_eq "--mode" "remote"
}
flag "--mode <mode>"
"#);
assert!(
entry_of(&out, "token").contains(
"RequiredIfEq: []argv.ValueCondition{{Key: FlagMode, Value: \"remote\"}}"
),
"{out}"
);
assert!(out.contains("resolved := map[uint64][]string{}"), "{out}");
assert!(out.contains("argv.CheckRelationshipsWithValues"), "{out}");
}
#[test]
fn boolean_sources_are_normalized_for_value_relationships() {
let out = go(r#"
name "ex"
bin "ex"
flag "--token <token>" {
required_if_eq "--mode" "true"
}
flag "--mode" negate="--no-mode" bool_value=#true
"#);
assert!(
entry_of(&out, "mode").contains("RequiresIfBoolean: true"),
"{out}"
);
}
#[test]
fn a_conditional_default_reaches_go_metadata() {
let out = go(r#"
name "ex"
bin "ex"
flag "--bin-names" {
default_if "--json" "true"
default_if "--output" "json" "pretty"
}
flag "--json"
flag "--output <fmt>"
"#);
assert!(
entry_of(&out, "bin-names")
.contains("DefaultIf: []argv.DefaultIf{{Key: FlagJson, Value: \"true\"}"),
"{out}"
);
assert!(
entry_of(&out, "bin-names").contains("When: \"json\""),
"{out}"
);
assert!(
out.contains("argv.ApplyDefaultIf"),
"the emitted parser must apply the metadata:\n{out}"
);
assert!(
out.contains("negated[ev.Flag.Key] = ev.Negated"),
"Equals default_if needs the negate form:\n{out}"
);
}
#[test]
fn a_relationship_needs_the_right_form() {
let out = go(r#"
name "ex"
bin "ex"
flag "-q --quiet"
flag "--color"
flag "--a" conflicts="--q"
flag "--b" conflicts="-color"
flag "--c" conflicts="-q"
flag "--d" conflicts="--color"
"#);
assert!(!entry_of(&out, "a").contains("Conflicts"), "{out}");
assert!(!entry_of(&out, "b").contains("Conflicts"), "{out}");
assert!(
entry_of(&out, "c").contains("Conflicts: []uint64{FlagQuiet}"),
"{out}"
);
assert!(
entry_of(&out, "d").contains("Conflicts: []uint64{FlagColor}"),
"{out}"
);
}
#[test]
fn an_ordinary_form_beats_another_flags_negation() {
let out = go(r#"
name "ex"
bin "ex"
flag "--a" negate="--zap"
flag "--zap"
flag "--p" conflicts="--zap"
"#);
assert!(
entry_of(&out, "p").contains("Conflicts: []uint64{FlagZap}"),
"should name the flag `--zap` binds, not the one negating to it:\n{out}"
);
}
#[test]
fn a_single_dash_negation_is_named_by_its_own_form() {
let out = go(r#"
name "ex"
bin "ex"
flag "--tint" negate="-no-tint"
flag "--plain" conflicts="-no-tint"
flag "--other" conflicts="--no-tint"
"#);
assert!(
entry_of(&out, "plain").contains("Conflicts: []uint64{FlagTint}"),
"the exact form should resolve:\n{out}"
);
assert!(
!entry_of(&out, "other").contains("Conflicts"),
"`--no-tint` is not how it was declared:\n{out}"
);
}
#[test]
fn a_negation_is_matched_as_written() {
let out = go(r#"
name "ex"
bin "ex"
flag "--color" negate="--no-color"
flag "--tint" negate="-no-tint"
flag "--a" conflicts="--no-color"
flag "--b" conflicts="--no-tint"
"#);
assert!(
entry_of(&out, "a").contains("Conflicts: []uint64{FlagColor}"),
"{out}"
);
assert!(!entry_of(&out, "b").contains("Conflicts"), "{out}");
}
#[test]
fn strings_are_escaped_to_go_rules() {
assert_eq!(go_string(r#"a"b\c"#), r#""a\"b\\c""#);
assert_eq!(go_string("tab\there"), r#""tab\there""#);
assert_eq!(go_string("\u{7f}"), r#""\x7f""#);
}
}