Skip to main content

ctl_core/
help.rs

1//! Styled help extracted from Clap and rendered as a semantic document.
2//!
3//! [`App`] prints help for you. These functions serve a CLI that
4//! drives its own lifecycle. [`document`] turns a Clap command into a
5//! [`Document`], and the `try_emit` functions print it when the arguments
6//! ask for help. Help uses the same renderer, theme, and width policy as
7//! command output.
8//!
9//! ```
10//! use clap::Parser;
11//! use ctl_core::help::document;
12//!
13//! #[derive(Parser)]
14//! #[command(name = "toy", about = "A toy")]
15//! struct Cli {}
16//!
17//! let help = document(<Cli as clap::CommandFactory>::command());
18//! assert!(!help.is_empty());
19//! ```
20#![cfg_attr(feature = "app", doc = "", doc = "[`App`]: crate::App")]
21#![cfg_attr(not(feature = "app"), doc = "", doc = "[`App`]: crate#cargo-features")]
22
23use std::io;
24
25use clap::{Command, CommandFactory};
26use ctl_term::Stream;
27
28use crate::document::{Document, Role, Section, Table, Text};
29use crate::render::RenderOptions;
30
31const NARROW_HELP_WIDTH: u16 = 64;
32
33/// Print styled help to stdout when the process argv asks for it, and return
34/// `true`. Return `false` and print nothing otherwise.
35///
36/// Colour comes from the `--color` and `--no-color` flags in argv. Use
37/// [`try_emit_with_options`] to control the width and style policy.
38///
39/// # Errors
40///
41/// Returns the error from writing help to stdout.
42///
43/// # Panics
44///
45/// Panics when the command disables `-h/--help` or `-V/--version`.
46pub fn try_emit<C: CommandFactory>() -> io::Result<bool> {
47    let raw = std::env::args_os().collect::<Vec<_>>();
48    let color = crate::parser::parsed_output::<C>(&raw).color;
49    try_emit_with_options::<C>(RenderOptions::new(color))
50}
51
52/// Like [`try_emit`], rendering with `options` in place of the defaults.
53///
54/// # Errors
55///
56/// Returns the error from writing help to stdout.
57///
58/// # Panics
59///
60/// Panics when the command disables `-h/--help` or `-V/--version`.
61pub fn try_emit_with_options<C: CommandFactory>(options: RenderOptions) -> io::Result<bool> {
62    let args = std::env::args_os()
63        .map(|arg| arg.to_string_lossy().into_owned())
64        .collect::<Vec<_>>();
65    try_emit_from_with_options::<C>(&args, options)
66}
67
68/// Like [`try_emit`], reading `args` in place of the process argv. The first
69/// item is the binary name.
70///
71/// # Errors
72///
73/// Returns the error from writing help to stdout.
74///
75/// # Panics
76///
77/// Panics when the command disables `-h/--help` or `-V/--version`.
78pub fn try_emit_from<C: CommandFactory>(args: &[String]) -> io::Result<bool> {
79    let raw = args
80        .iter()
81        .map(std::ffi::OsString::from)
82        .collect::<Vec<_>>();
83    let color = crate::parser::parsed_output::<C>(&raw).color;
84    try_emit_from_with_options::<C>(args, RenderOptions::new(color))
85}
86
87/// Like [`try_emit`], reading `args` and rendering with `options`.
88///
89/// # Errors
90///
91/// Returns the error from writing help to stdout.
92///
93/// # Panics
94///
95/// Panics when the command disables `-h/--help` or `-V/--version`.
96pub fn try_emit_from_with_options<C: CommandFactory>(
97    args: &[String],
98    options: RenderOptions,
99) -> io::Result<bool> {
100    let raw = args
101        .iter()
102        .map(std::ffi::OsString::from)
103        .collect::<Vec<_>>();
104    if !crate::parser::wants_help::<C>(&raw) {
105        return Ok(false);
106    }
107    emit_selected::<C>(args, options)?;
108    Ok(true)
109}
110
111/// Print the root command's help to stderr, as shown for a bare invocation of
112/// a command that requires input.
113///
114/// Colour and width are decided for stderr, not stdout. Help printed after a
115/// usage error then fits the terminal stderr writes to, even when stdout is a
116/// pipe.
117///
118/// # Errors
119///
120/// Returns the error from writing help to stderr.
121pub fn emit_bare_with_options<C: CommandFactory>(options: RenderOptions) -> io::Result<()> {
122    options.print_for(Stream::Stderr, &document(C::command()))
123}
124
125/// Help for the command `args` select, to stdout, as a help request prints it.
126pub(crate) fn emit_selected<C: CommandFactory>(
127    args: &[String],
128    options: RenderOptions,
129) -> io::Result<()> {
130    options.print_for(Stream::Stdout, &document(help_command::<C>(args)))
131}
132
133/// Help for the command `args` select, to stderr. Clap asks for it when a
134/// command that requires an argument or a subcommand gets neither.
135#[cfg(feature = "app")]
136pub(crate) fn emit_selected_to_stderr<C: CommandFactory>(
137    args: &[String],
138    options: RenderOptions,
139) -> io::Result<()> {
140    options.print_for(Stream::Stderr, &document(help_command::<C>(args)))
141}
142
143fn help_command<C: CommandFactory>(args: &[String]) -> Command {
144    let declared = C::command();
145    let mut root = crate::parser::with_help(declared.clone());
146    root.build();
147    select_command(root, declared, args.get(1..).unwrap_or(&[]))
148}
149
150fn select_command(mut command: Command, mut declared: Command, args: &[String]) -> Command {
151    for value in args {
152        if value == "-h" || value == "--help" {
153            break;
154        }
155        if value.starts_with('-') {
156            continue;
157        }
158        let declared_next = declared
159            .get_subcommands()
160            .find(|subcommand| subcommand.get_name() == value)
161            .cloned();
162        if value == "help" && declared_next.is_none() {
163            continue;
164        }
165        let Some(next) = command
166            .get_subcommands()
167            .find(|subcommand| subcommand.get_name() == value)
168            .cloned()
169        else {
170            continue;
171        };
172        command = next;
173        if let Some(next) = declared_next {
174            declared = next;
175        }
176    }
177    command
178}
179
180/// Build the help document of a Clap command.
181///
182/// The document holds the usage line, the about text, a `Commands` section,
183/// an `Arguments` section, and one section per option help heading
184/// (`Options` when an option names none). ctl-core's `-h/--help` flag and
185/// `help` subcommand are added, and the command is built first, so derived
186/// values such as the version flag are present. Hidden subcommands,
187/// arguments, and options are left out.
188///
189/// # Panics
190///
191/// In a debug build, panics when Clap's `debug_assert` finds an invalid
192/// command definition, as [`parser::verify`](crate::parser::verify) does.
193#[must_use]
194pub fn document(command: Command) -> Document {
195    let mut command = crate::parser::with_help(command);
196    command.build();
197    let usage = command.render_usage().to_string();
198    let mut output =
199        Document::new().paragraph(Text::new().span(Role::Heading, usage.trim().to_owned()));
200    if let Some(about) = command.get_about() {
201        output = output.paragraph(about.to_string());
202    }
203
204    let commands = command
205        .get_subcommands()
206        .filter(|subcommand| !subcommand.is_hide_set())
207        .fold(Table::plain().token_column(0), |table, subcommand| {
208            table.row([
209                Text::plain(subcommand.get_name()),
210                Text::plain(
211                    subcommand
212                        .get_about()
213                        .map_or_else(String::new, ToString::to_string),
214                ),
215            ])
216        });
217    if !commands.is_empty() {
218        output = output.section(Section::new(
219            "Commands",
220            Document::new().table(commands.stacked_below(NARROW_HELP_WIDTH, 1)),
221        ));
222    }
223
224    let positionals = command
225        .get_positionals()
226        .filter(|arg| !arg.is_hide_set())
227        .fold(Table::plain(), |table, arg| {
228            table.row([
229                Text::new(),
230                Text::new().value(value_label(arg)),
231                Text::new(),
232                description(arg),
233            ])
234        });
235    if !positionals.is_empty() {
236        output = output.section(Section::new(
237            "Arguments",
238            Document::new().table(positionals.stacked_below(NARROW_HELP_WIDTH, 3)),
239        ));
240    }
241
242    let mut headings = Vec::<String>::new();
243    for arg in command
244        .get_arguments()
245        .filter(|arg| !arg.is_positional() && !arg.is_hide_set() && arg.get_id().as_str() != "help")
246    {
247        let heading = arg
248            .get_help_heading()
249            .map_or_else(|| "Options".to_owned(), ToString::to_string);
250        if !headings.contains(&heading) {
251            headings.push(heading);
252        }
253    }
254    if command
255        .get_arguments()
256        .any(|arg| arg.get_id().as_str() == "help")
257    {
258        headings.push("Help".into());
259    }
260    for heading in headings {
261        let rows = command
262            .get_arguments()
263            .filter(|arg| {
264                if heading == "Help" {
265                    return arg.get_id().as_str() == "help";
266                }
267                !arg.is_positional()
268                    && !arg.is_hide_set()
269                    && arg.get_id().as_str() != "help"
270                    && arg.get_help_heading().map_or("Options", |value| value) == heading
271            })
272            .fold(Table::plain(), |table, arg| {
273                table.row([
274                    arg.get_short()
275                        .map_or_else(Text::new, |value| Text::new().token(format!("-{value}"))),
276                    arg.get_long()
277                        .map_or_else(Text::new, |value| Text::new().token(format!("--{value}"))),
278                    Text::new().value(value_label(arg)),
279                    description(arg),
280                ])
281            });
282        if !rows.is_empty() {
283            output = output.section(Section::new(
284                heading,
285                Document::new().table(rows.stacked_below(NARROW_HELP_WIDTH, 3)),
286            ));
287        }
288    }
289    output
290}
291
292fn value_label(arg: &clap::Arg) -> String {
293    if matches!(
294        arg.get_action(),
295        clap::ArgAction::SetTrue
296            | clap::ArgAction::SetFalse
297            | clap::ArgAction::Help
298            | clap::ArgAction::Version
299    ) {
300        return String::new();
301    }
302    let names = arg
303        .get_value_names()
304        .map(|names| {
305            names
306                .iter()
307                .map(ToString::to_string)
308                .collect::<Vec<_>>()
309                .join(" ")
310        })
311        .unwrap_or_default();
312    let choices = arg
313        .get_possible_values()
314        .into_iter()
315        .filter(|value| !value.is_hide_set())
316        .map(|value| value.get_name().to_owned())
317        .collect::<Vec<_>>();
318    if choices.is_empty() {
319        names
320    } else {
321        format!("[{}]", choices.join("|"))
322    }
323}
324
325/// The help text, then `[default: …]` and `[env: …]` notes in muted style.
326/// An option marked `hide_env` keeps its variable out of help, as in clap.
327fn description(arg: &clap::Arg) -> Text {
328    let description = arg.get_help().map_or_else(String::new, ToString::to_string);
329    let defaults = arg
330        .get_default_values()
331        .iter()
332        .map(|value| value.to_string_lossy())
333        .collect::<Vec<_>>();
334    let mut notes = Vec::new();
335    if !defaults.is_empty()
336        && !matches!(
337            arg.get_action(),
338            clap::ArgAction::SetTrue | clap::ArgAction::SetFalse
339        )
340    {
341        notes.push(format!("[default: {}]", defaults.join(", ")));
342    }
343    if let Some(name) = arg.get_env().filter(|_| !arg.is_hide_env_set()) {
344        notes.push(format!("[env: {}]", name.to_string_lossy()));
345    }
346    let mut separator = if description.is_empty() { "" } else { " " };
347    let mut text = Text::plain(description);
348    for note in notes {
349        text = text.then(separator).muted(note);
350        separator = " ";
351    }
352    text
353}
354
355#[cfg(test)]
356mod tests {
357    use clap::{CommandFactory, Parser};
358
359    use super::{document, help_command, try_emit_from};
360    use crate::color::ColorMode;
361    use crate::flags::{DryRunArgs, OutputArgs};
362    use crate::render::RenderOptions;
363
364    #[derive(Parser)]
365    #[command(version, about = "toy ctl", arg_required_else_help = true)]
366    struct Toy {
367        #[command(flatten)]
368        output: OutputArgs,
369        #[command(flatten)]
370        dry: DryRunArgs,
371        #[command(subcommand)]
372        command: ToyCmd,
373    }
374
375    #[derive(clap::Subcommand)]
376    enum ToyCmd {
377        /// Show status.
378        Status(StatusArgs),
379        /// Group commands.
380        Group {
381            #[command(subcommand)]
382            command: GroupCmd,
383        },
384    }
385
386    #[derive(clap::Subcommand)]
387    enum GroupCmd {
388        /// Show a nested item.
389        Show,
390    }
391
392    #[derive(clap::Args)]
393    struct StatusArgs {
394        /// Domain text that may begin with a hyphen.
395        #[arg(short = 'm', long, allow_hyphen_values = true)]
396        message: Option<String>,
397    }
398
399    #[test]
400    fn document_lists_commands_and_flags() {
401        let text = document(Toy::command()).render(RenderOptions::new(ColorMode::Never).width(80));
402        assert!(text.contains("Commands"));
403        assert!(text.contains("status"));
404        assert!(text.contains("--dry-run"));
405        assert!(text.contains("--format"));
406        assert!(text.contains("--no-color"));
407    }
408
409    #[test]
410    fn colorless_help_has_no_ansi() {
411        let text = document(Toy::command()).render(RenderOptions::new(ColorMode::Never).width(80));
412        assert!(!text.contains('\u{1b}'));
413    }
414
415    #[test]
416    fn long_usage_wraps_to_the_render_width() {
417        let command = clap::Command::new("refresh")
418            .bin_name("forkctl patch refresh")
419            .arg(
420                clap::Arg::new("rewrite")
421                    .long("rewrite-below")
422                    .action(clap::ArgAction::SetTrue),
423            )
424            .arg(clap::Arg::new("name").value_name("NAME"));
425        let text = document(command).render(RenderOptions::new(ColorMode::Never).width(40));
426        assert!(text.lines().all(|line| line.chars().count() <= 40));
427        assert_eq!(
428            text.lines().take(2).collect::<Vec<_>>(),
429            ["Usage: forkctl patch refresh [OPTIONS]", "[NAME]"]
430        );
431    }
432
433    #[test]
434    fn help_names_a_variable_unless_it_is_hidden() {
435        let command = clap::Command::new("toy")
436            .arg(clap::Arg::new("config").long("config").env("TOY_CONFIG"))
437            .arg(
438                clap::Arg::new("token")
439                    .long("token")
440                    .env("TOY_TOKEN")
441                    .hide_env(true),
442            );
443        let text = document(command).render(RenderOptions::new(ColorMode::Never).width(80));
444        assert!(text.contains("[env: TOY_CONFIG]"), "{text}");
445        assert!(!text.contains("TOY_TOKEN"), "{text}");
446    }
447
448    #[test]
449    fn help_subcommand_selects_the_requested_command() {
450        let args = ["toy", "help", "status"].map(String::from);
451        assert_eq!(help_command::<Toy>(&args).get_name(), "status");
452    }
453
454    #[test]
455    fn subcommand_help_keeps_parent_usage_and_globals() {
456        let command = help_command::<Toy>(&["toy", "status", "--help"].map(String::from));
457        assert_eq!(command.get_bin_name(), Some("ctl-core status"));
458        assert!(command.get_arguments().any(|arg| arg.get_id() == "format"));
459    }
460
461    #[test]
462    fn nested_help_subcommand_selects_the_requested_command() {
463        let args = ["toy", "group", "help", "show"].map(String::from);
464        assert_eq!(help_command::<Toy>(&args).get_name(), "show");
465    }
466
467    #[test]
468    fn try_emit_skips_without_help() {
469        let args = ["toy", "status"]
470            .into_iter()
471            .map(String::from)
472            .collect::<Vec<_>>();
473        assert!(!try_emit_from::<Toy>(&args).unwrap());
474    }
475
476    #[test]
477    fn try_emit_ignores_help_used_as_a_domain_value() {
478        let args = ["toy", "status", "-m", "--help"]
479            .into_iter()
480            .map(String::from)
481            .collect::<Vec<_>>();
482        assert!(!try_emit_from::<Toy>(&args).unwrap());
483    }
484
485    #[test]
486    fn try_emit_ignores_help_after_separator() {
487        let args = ["toy", "status", "--", "--help"]
488            .into_iter()
489            .map(String::from)
490            .collect::<Vec<_>>();
491        assert!(!try_emit_from::<Toy>(&args).unwrap());
492    }
493
494    #[test]
495    fn try_emit_does_not_claim_bare_invocation() {
496        let args = ["toy"].map(String::from);
497        assert!(!try_emit_from::<Toy>(&args).unwrap());
498    }
499}