use std::io::{self, Write};
use clap::{Command, CommandFactory};
use crate::color::ColorMode;
use crate::document::{Document, Role, Section, Table, Text};
use crate::render::RenderOptions;
const NARROW_HELP_WIDTH: u16 = 64;
pub fn try_emit<C: CommandFactory>() -> io::Result<bool> {
let raw = std::env::args_os().collect::<Vec<_>>();
let args = raw
.iter()
.map(|arg| arg.to_string_lossy().into_owned())
.collect::<Vec<_>>();
let color = crate::parser::parsed_output::<C>(&raw).color;
try_emit_from_with_color::<C>(&args, color)
}
pub fn try_emit_from<C: CommandFactory>(args: &[String]) -> io::Result<bool> {
let raw = args
.iter()
.map(std::ffi::OsString::from)
.collect::<Vec<_>>();
let color = crate::parser::parsed_output::<C>(&raw).color;
try_emit_from_with_color::<C>(args, color)
}
pub(crate) fn try_emit_from_with_color<C: CommandFactory>(
args: &[String],
color: ColorMode,
) -> io::Result<bool> {
let raw = args
.iter()
.map(std::ffi::OsString::from)
.collect::<Vec<_>>();
if !crate::parser::wants_help::<C>(&raw) {
return Ok(false);
}
let command = help_command::<C>(args);
let output = document(command).render(RenderOptions::new(color));
let mut stream = anstream::AutoStream::new(io::stdout().lock(), color.choice());
stream.write_all(output.as_bytes())?;
stream.flush()?;
Ok(true)
}
pub(crate) fn emit_bare<C: CommandFactory>(color: ColorMode) -> io::Result<()> {
let output = document(C::command()).render(RenderOptions::new(color));
let mut stream = anstream::AutoStream::new(io::stderr().lock(), color.choice());
stream.write_all(output.as_bytes())?;
stream.flush()
}
fn help_command<C: CommandFactory>(args: &[String]) -> Command {
let declared = C::command();
let mut root = declared.clone();
root.build();
select_command(root, declared, args.get(1..).unwrap_or(&[]))
}
fn select_command(mut command: Command, mut declared: Command, args: &[String]) -> Command {
for value in args {
if value == "-h" || value == "--help" {
break;
}
if value.starts_with('-') {
continue;
}
let declared_next = declared
.get_subcommands()
.find(|subcommand| subcommand.get_name() == value)
.cloned();
if value == "help" && declared_next.is_none() {
continue;
}
let Some(next) = command
.get_subcommands()
.find(|subcommand| subcommand.get_name() == value)
.cloned()
else {
continue;
};
command = next;
if let Some(next) = declared_next {
declared = next;
}
}
command
}
#[must_use]
pub fn document(mut command: Command) -> Document {
command.build();
let usage = command.render_usage().to_string();
let mut output =
Document::new().paragraph(Text::new().span(Role::Heading, usage.trim().to_owned()));
if let Some(about) = command.get_about() {
output = output.paragraph(about.to_string());
}
let commands = command
.get_subcommands()
.filter(|subcommand| !subcommand.is_hide_set())
.fold(Table::plain().token_column(0), |table, subcommand| {
table.row([
Text::plain(subcommand.get_name()),
Text::plain(
subcommand
.get_about()
.map_or_else(String::new, ToString::to_string),
),
])
});
if !commands.is_empty() {
output = output.section(Section::new(
"Commands",
Document::new().table(commands.stacked_below(NARROW_HELP_WIDTH, 1)),
));
}
let positionals = command
.get_positionals()
.filter(|arg| !arg.is_hide_set())
.fold(Table::plain(), |table, arg| {
table.row([
Text::new(),
Text::new().value(value_label(arg)),
Text::new(),
description(arg),
])
});
if !positionals.is_empty() {
output = output.section(Section::new(
"Arguments",
Document::new().table(positionals.stacked_below(NARROW_HELP_WIDTH, 3)),
));
}
let mut headings = Vec::<String>::new();
for arg in command
.get_arguments()
.filter(|arg| !arg.is_positional() && !arg.is_hide_set() && arg.get_id().as_str() != "help")
{
let heading = arg
.get_help_heading()
.map_or_else(|| "Options".to_owned(), ToString::to_string);
if !headings.contains(&heading) {
headings.push(heading);
}
}
if command
.get_arguments()
.any(|arg| arg.get_id().as_str() == "help")
{
headings.push("Help".into());
}
for heading in headings {
let rows = command
.get_arguments()
.filter(|arg| {
if heading == "Help" {
return arg.get_id().as_str() == "help";
}
!arg.is_positional()
&& !arg.is_hide_set()
&& arg.get_id().as_str() != "help"
&& arg.get_help_heading().map_or("Options", |value| value) == heading
})
.fold(Table::plain(), |table, arg| {
table.row([
arg.get_short()
.map_or_else(Text::new, |value| Text::new().token(format!("-{value}"))),
arg.get_long()
.map_or_else(Text::new, |value| Text::new().token(format!("--{value}"))),
Text::new().value(value_label(arg)),
description(arg),
])
});
if !rows.is_empty() {
output = output.section(Section::new(
heading,
Document::new().table(rows.stacked_below(NARROW_HELP_WIDTH, 3)),
));
}
}
output
}
fn value_label(arg: &clap::Arg) -> String {
if matches!(
arg.get_action(),
clap::ArgAction::SetTrue
| clap::ArgAction::SetFalse
| clap::ArgAction::Help
| clap::ArgAction::Version
) {
return String::new();
}
let names = arg
.get_value_names()
.map(|names| {
names
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(" ")
})
.unwrap_or_default();
let choices = arg
.get_possible_values()
.into_iter()
.filter(|value| !value.is_hide_set())
.map(|value| value.get_name().to_owned())
.collect::<Vec<_>>();
if choices.is_empty() {
names
} else {
format!("[{}]", choices.join("|"))
}
}
fn description(arg: &clap::Arg) -> Text {
let description = arg.get_help().map_or_else(String::new, ToString::to_string);
let defaults = arg
.get_default_values()
.iter()
.map(|value| value.to_string_lossy())
.collect::<Vec<_>>();
if defaults.is_empty()
|| matches!(
arg.get_action(),
clap::ArgAction::SetTrue | clap::ArgAction::SetFalse
)
{
return Text::plain(description);
}
let separator = if description.is_empty() { "" } else { " " };
Text::plain(description)
.then(separator)
.muted(format!("[default: {}]", defaults.join(", ")))
}
#[cfg(test)]
mod tests {
use clap::{CommandFactory, Parser};
use super::{document, help_command, try_emit_from};
use crate::color::ColorMode;
use crate::flags::{DryRunArgs, OutputArgs};
use crate::render::RenderOptions;
#[derive(Parser)]
#[command(version, about = "toy ctl", arg_required_else_help = true)]
struct Toy {
#[command(flatten)]
output: OutputArgs,
#[command(flatten)]
dry: DryRunArgs,
#[command(subcommand)]
command: ToyCmd,
}
#[derive(clap::Subcommand)]
enum ToyCmd {
Status(StatusArgs),
Group {
#[command(subcommand)]
command: GroupCmd,
},
}
#[derive(clap::Subcommand)]
enum GroupCmd {
Show,
}
#[derive(clap::Args)]
struct StatusArgs {
#[arg(short = 'm', long, allow_hyphen_values = true)]
message: Option<String>,
}
#[test]
fn document_lists_commands_and_flags() {
let text = document(Toy::command()).render(RenderOptions::new(ColorMode::Never).width(80));
assert!(text.contains("Commands"));
assert!(text.contains("status"));
assert!(text.contains("--dry-run"));
assert!(text.contains("--format"));
assert!(text.contains("--no-color"));
}
#[test]
fn colorless_help_has_no_ansi() {
let text = document(Toy::command()).render(RenderOptions::new(ColorMode::Never).width(80));
assert!(!text.contains('\u{1b}'));
}
#[test]
fn long_usage_wraps_to_the_render_width() {
let command = clap::Command::new("refresh")
.bin_name("forkctl patch refresh")
.arg(
clap::Arg::new("rewrite")
.long("rewrite-below")
.action(clap::ArgAction::SetTrue),
)
.arg(clap::Arg::new("name").value_name("NAME"));
let text = document(command).render(RenderOptions::new(ColorMode::Never).width(40));
assert!(text.lines().all(|line| line.chars().count() <= 40));
assert_eq!(
text.lines().take(2).collect::<Vec<_>>(),
["Usage: forkctl patch refresh [OPTIONS]", "[NAME]"]
);
}
#[test]
fn help_subcommand_selects_the_requested_command() {
let args = ["toy", "help", "status"].map(String::from);
assert_eq!(help_command::<Toy>(&args).get_name(), "status");
}
#[test]
fn subcommand_help_keeps_parent_usage_and_globals() {
let command = help_command::<Toy>(&["toy", "status", "--help"].map(String::from));
assert_eq!(command.get_bin_name(), Some("ctl-core status"));
assert!(command.get_arguments().any(|arg| arg.get_id() == "format"));
}
#[test]
fn nested_help_subcommand_selects_the_requested_command() {
let args = ["toy", "group", "help", "show"].map(String::from);
assert_eq!(help_command::<Toy>(&args).get_name(), "show");
}
#[test]
fn try_emit_skips_without_help() {
let args = ["toy", "status"]
.into_iter()
.map(String::from)
.collect::<Vec<_>>();
assert!(!try_emit_from::<Toy>(&args).unwrap());
}
#[test]
fn try_emit_ignores_help_used_as_a_domain_value() {
let args = ["toy", "status", "-m", "--help"]
.into_iter()
.map(String::from)
.collect::<Vec<_>>();
assert!(!try_emit_from::<Toy>(&args).unwrap());
}
#[test]
fn try_emit_ignores_help_after_separator() {
let args = ["toy", "status", "--", "--help"]
.into_iter()
.map(String::from)
.collect::<Vec<_>>();
assert!(!try_emit_from::<Toy>(&args).unwrap());
}
#[test]
fn try_emit_does_not_claim_bare_invocation() {
let args = ["toy"].map(String::from);
assert!(!try_emit_from::<Toy>(&args).unwrap());
}
}