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