Skip to main content

ctl_core/
help.rs

1//! Styled help extracted from Clap and rendered as a semantic document.
2
3use std::io::{self, Write};
4
5use clap::{Command, CommandFactory};
6
7use crate::document::{Document, Role, Section, Table, Text};
8use crate::render::RenderOptions;
9
10const NARROW_HELP_WIDTH: u16 = 64;
11
12/// Styled `-h` / `--help`. Returns `true` when help ran.
13pub fn try_emit<C: CommandFactory>() -> io::Result<bool> {
14    let raw = std::env::args_os().collect::<Vec<_>>();
15    let color = crate::parser::parsed_output::<C>(&raw).color;
16    try_emit_with_options::<C>(RenderOptions::new(color))
17}
18
19/// Styled help with rendering options supplied by the library owner.
20pub fn try_emit_with_options<C: CommandFactory>(options: RenderOptions) -> io::Result<bool> {
21    let args = std::env::args_os()
22        .map(|arg| arg.to_string_lossy().into_owned())
23        .collect::<Vec<_>>();
24    try_emit_from_with_options::<C>(&args, options)
25}
26
27/// Same as [`try_emit`] with explicit argv.
28pub fn try_emit_from<C: CommandFactory>(args: &[String]) -> io::Result<bool> {
29    let raw = args
30        .iter()
31        .map(std::ffi::OsString::from)
32        .collect::<Vec<_>>();
33    let color = crate::parser::parsed_output::<C>(&raw).color;
34    try_emit_from_with_options::<C>(args, RenderOptions::new(color))
35}
36
37/// Explicit-argv help with rendering options supplied by the owning library.
38pub fn try_emit_from_with_options<C: CommandFactory>(
39    args: &[String],
40    options: RenderOptions,
41) -> io::Result<bool> {
42    let raw = args
43        .iter()
44        .map(std::ffi::OsString::from)
45        .collect::<Vec<_>>();
46    if !crate::parser::wants_help::<C>(&raw) {
47        return Ok(false);
48    }
49    let command = help_command::<C>(args);
50    let output = document(command).render(options);
51    let mut stream = anstream::AutoStream::new(io::stdout().lock(), options.color().choice());
52    stream.write_all(output.as_bytes())?;
53    stream.flush()?;
54    Ok(true)
55}
56
57/// Render root help to stderr with rendering options supplied by the owner.
58pub fn emit_bare_with_options<C: CommandFactory>(options: RenderOptions) -> io::Result<()> {
59    let output = document(C::command()).render(options);
60    let mut stream = anstream::AutoStream::new(io::stderr().lock(), options.color().choice());
61    stream.write_all(output.as_bytes())?;
62    stream.flush()
63}
64
65fn help_command<C: CommandFactory>(args: &[String]) -> Command {
66    let declared = C::command();
67    let mut root = declared.clone();
68    root.build();
69    select_command(root, declared, args.get(1..).unwrap_or(&[]))
70}
71
72fn select_command(mut command: Command, mut declared: Command, args: &[String]) -> Command {
73    for value in args {
74        if value == "-h" || value == "--help" {
75            break;
76        }
77        if value.starts_with('-') {
78            continue;
79        }
80        let declared_next = declared
81            .get_subcommands()
82            .find(|subcommand| subcommand.get_name() == value)
83            .cloned();
84        if value == "help" && declared_next.is_none() {
85            continue;
86        }
87        let Some(next) = command
88            .get_subcommands()
89            .find(|subcommand| subcommand.get_name() == value)
90            .cloned()
91        else {
92            continue;
93        };
94        command = next;
95        if let Some(next) = declared_next {
96            declared = next;
97        }
98    }
99    command
100}
101
102/// Extract one semantic help document from a Clap command.
103#[must_use]
104pub fn document(mut command: Command) -> Document {
105    command.build();
106    let usage = command.render_usage().to_string();
107    let mut output =
108        Document::new().paragraph(Text::new().span(Role::Heading, usage.trim().to_owned()));
109    if let Some(about) = command.get_about() {
110        output = output.paragraph(about.to_string());
111    }
112
113    let commands = command
114        .get_subcommands()
115        .filter(|subcommand| !subcommand.is_hide_set())
116        .fold(Table::plain().token_column(0), |table, subcommand| {
117            table.row([
118                Text::plain(subcommand.get_name()),
119                Text::plain(
120                    subcommand
121                        .get_about()
122                        .map_or_else(String::new, ToString::to_string),
123                ),
124            ])
125        });
126    if !commands.is_empty() {
127        output = output.section(Section::new(
128            "Commands",
129            Document::new().table(commands.stacked_below(NARROW_HELP_WIDTH, 1)),
130        ));
131    }
132
133    let positionals = command
134        .get_positionals()
135        .filter(|arg| !arg.is_hide_set())
136        .fold(Table::plain(), |table, arg| {
137            table.row([
138                Text::new(),
139                Text::new().value(value_label(arg)),
140                Text::new(),
141                description(arg),
142            ])
143        });
144    if !positionals.is_empty() {
145        output = output.section(Section::new(
146            "Arguments",
147            Document::new().table(positionals.stacked_below(NARROW_HELP_WIDTH, 3)),
148        ));
149    }
150
151    let mut headings = Vec::<String>::new();
152    for arg in command
153        .get_arguments()
154        .filter(|arg| !arg.is_positional() && !arg.is_hide_set() && arg.get_id().as_str() != "help")
155    {
156        let heading = arg
157            .get_help_heading()
158            .map_or_else(|| "Options".to_owned(), ToString::to_string);
159        if !headings.contains(&heading) {
160            headings.push(heading);
161        }
162    }
163    if command
164        .get_arguments()
165        .any(|arg| arg.get_id().as_str() == "help")
166    {
167        headings.push("Help".into());
168    }
169    for heading in headings {
170        let rows = command
171            .get_arguments()
172            .filter(|arg| {
173                if heading == "Help" {
174                    return arg.get_id().as_str() == "help";
175                }
176                !arg.is_positional()
177                    && !arg.is_hide_set()
178                    && arg.get_id().as_str() != "help"
179                    && arg.get_help_heading().map_or("Options", |value| value) == heading
180            })
181            .fold(Table::plain(), |table, arg| {
182                table.row([
183                    arg.get_short()
184                        .map_or_else(Text::new, |value| Text::new().token(format!("-{value}"))),
185                    arg.get_long()
186                        .map_or_else(Text::new, |value| Text::new().token(format!("--{value}"))),
187                    Text::new().value(value_label(arg)),
188                    description(arg),
189                ])
190            });
191        if !rows.is_empty() {
192            output = output.section(Section::new(
193                heading,
194                Document::new().table(rows.stacked_below(NARROW_HELP_WIDTH, 3)),
195            ));
196        }
197    }
198    output
199}
200
201fn value_label(arg: &clap::Arg) -> String {
202    if matches!(
203        arg.get_action(),
204        clap::ArgAction::SetTrue
205            | clap::ArgAction::SetFalse
206            | clap::ArgAction::Help
207            | clap::ArgAction::Version
208    ) {
209        return String::new();
210    }
211    let names = arg
212        .get_value_names()
213        .map(|names| {
214            names
215                .iter()
216                .map(ToString::to_string)
217                .collect::<Vec<_>>()
218                .join(" ")
219        })
220        .unwrap_or_default();
221    let choices = arg
222        .get_possible_values()
223        .into_iter()
224        .filter(|value| !value.is_hide_set())
225        .map(|value| value.get_name().to_owned())
226        .collect::<Vec<_>>();
227    if choices.is_empty() {
228        names
229    } else {
230        format!("[{}]", choices.join("|"))
231    }
232}
233
234/// The help text, then `[default: …]` and `[env: …]` notes in muted style.
235/// An option marked `hide_env` keeps its variable out of help, as in clap.
236fn description(arg: &clap::Arg) -> Text {
237    let description = arg.get_help().map_or_else(String::new, ToString::to_string);
238    let defaults = arg
239        .get_default_values()
240        .iter()
241        .map(|value| value.to_string_lossy())
242        .collect::<Vec<_>>();
243    let mut notes = Vec::new();
244    if !defaults.is_empty()
245        && !matches!(
246            arg.get_action(),
247            clap::ArgAction::SetTrue | clap::ArgAction::SetFalse
248        )
249    {
250        notes.push(format!("[default: {}]", defaults.join(", ")));
251    }
252    if let Some(name) = arg.get_env().filter(|_| !arg.is_hide_env_set()) {
253        notes.push(format!("[env: {}]", name.to_string_lossy()));
254    }
255    let mut separator = if description.is_empty() { "" } else { " " };
256    let mut text = Text::plain(description);
257    for note in notes {
258        text = text.then(separator).muted(note);
259        separator = " ";
260    }
261    text
262}
263
264#[cfg(test)]
265mod tests {
266    use clap::{CommandFactory, Parser};
267
268    use super::{document, help_command, try_emit_from};
269    use crate::color::ColorMode;
270    use crate::flags::{DryRunArgs, OutputArgs};
271    use crate::render::RenderOptions;
272
273    #[derive(Parser)]
274    #[command(version, about = "toy ctl", arg_required_else_help = true)]
275    struct Toy {
276        #[command(flatten)]
277        output: OutputArgs,
278        #[command(flatten)]
279        dry: DryRunArgs,
280        #[command(subcommand)]
281        command: ToyCmd,
282    }
283
284    #[derive(clap::Subcommand)]
285    enum ToyCmd {
286        /// Show status.
287        Status(StatusArgs),
288        /// Group commands.
289        Group {
290            #[command(subcommand)]
291            command: GroupCmd,
292        },
293    }
294
295    #[derive(clap::Subcommand)]
296    enum GroupCmd {
297        /// Show a nested item.
298        Show,
299    }
300
301    #[derive(clap::Args)]
302    struct StatusArgs {
303        /// Domain text that may begin with a hyphen.
304        #[arg(short = 'm', long, allow_hyphen_values = true)]
305        message: Option<String>,
306    }
307
308    #[test]
309    fn document_lists_commands_and_flags() {
310        let text = document(Toy::command()).render(RenderOptions::new(ColorMode::Never).width(80));
311        assert!(text.contains("Commands"));
312        assert!(text.contains("status"));
313        assert!(text.contains("--dry-run"));
314        assert!(text.contains("--format"));
315        assert!(text.contains("--no-color"));
316    }
317
318    #[test]
319    fn colorless_help_has_no_ansi() {
320        let text = document(Toy::command()).render(RenderOptions::new(ColorMode::Never).width(80));
321        assert!(!text.contains('\u{1b}'));
322    }
323
324    #[test]
325    fn long_usage_wraps_to_the_render_width() {
326        let command = clap::Command::new("refresh")
327            .bin_name("forkctl patch refresh")
328            .arg(
329                clap::Arg::new("rewrite")
330                    .long("rewrite-below")
331                    .action(clap::ArgAction::SetTrue),
332            )
333            .arg(clap::Arg::new("name").value_name("NAME"));
334        let text = document(command).render(RenderOptions::new(ColorMode::Never).width(40));
335        assert!(text.lines().all(|line| line.chars().count() <= 40));
336        assert_eq!(
337            text.lines().take(2).collect::<Vec<_>>(),
338            ["Usage: forkctl patch refresh [OPTIONS]", "[NAME]"]
339        );
340    }
341
342    #[test]
343    fn help_names_a_variable_unless_it_is_hidden() {
344        let command = clap::Command::new("toy")
345            .arg(clap::Arg::new("config").long("config").env("TOY_CONFIG"))
346            .arg(
347                clap::Arg::new("token")
348                    .long("token")
349                    .env("TOY_TOKEN")
350                    .hide_env(true),
351            );
352        let text = document(command).render(RenderOptions::new(ColorMode::Never).width(80));
353        assert!(text.contains("[env: TOY_CONFIG]"), "{text}");
354        assert!(!text.contains("TOY_TOKEN"), "{text}");
355    }
356
357    #[test]
358    fn help_subcommand_selects_the_requested_command() {
359        let args = ["toy", "help", "status"].map(String::from);
360        assert_eq!(help_command::<Toy>(&args).get_name(), "status");
361    }
362
363    #[test]
364    fn subcommand_help_keeps_parent_usage_and_globals() {
365        let command = help_command::<Toy>(&["toy", "status", "--help"].map(String::from));
366        assert_eq!(command.get_bin_name(), Some("ctl-core status"));
367        assert!(command.get_arguments().any(|arg| arg.get_id() == "format"));
368    }
369
370    #[test]
371    fn nested_help_subcommand_selects_the_requested_command() {
372        let args = ["toy", "group", "help", "show"].map(String::from);
373        assert_eq!(help_command::<Toy>(&args).get_name(), "show");
374    }
375
376    #[test]
377    fn try_emit_skips_without_help() {
378        let args = ["toy", "status"]
379            .into_iter()
380            .map(String::from)
381            .collect::<Vec<_>>();
382        assert!(!try_emit_from::<Toy>(&args).unwrap());
383    }
384
385    #[test]
386    fn try_emit_ignores_help_used_as_a_domain_value() {
387        let args = ["toy", "status", "-m", "--help"]
388            .into_iter()
389            .map(String::from)
390            .collect::<Vec<_>>();
391        assert!(!try_emit_from::<Toy>(&args).unwrap());
392    }
393
394    #[test]
395    fn try_emit_ignores_help_after_separator() {
396        let args = ["toy", "status", "--", "--help"]
397            .into_iter()
398            .map(String::from)
399            .collect::<Vec<_>>();
400        assert!(!try_emit_from::<Toy>(&args).unwrap());
401    }
402
403    #[test]
404    fn try_emit_does_not_claim_bare_invocation() {
405        let args = ["toy"].map(String::from);
406        assert!(!try_emit_from::<Toy>(&args).unwrap());
407    }
408}