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