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