Skip to main content

ctl_core/
parser.rs

1//! The parser rules every CLI built on ctl-core keeps: `-h` / `--help`, the
2//! `help` subcommand, and `-V` / `--version` stay on, and a repeated flag
3//! takes its last value.
4//!
5//! [`apply_defaults`] sets those rules on a Clap command, and [`verify`] is
6//! the test a CLI runs over its own parser.
7//!
8//! ctl-core declares `-h` / `--help` and `help` itself, so they work without
9//! Clap's `help` feature, which ctl-core leaves off. Clap turns both off
10//! without that feature ([clap_builder 4.6.6, `Command::_build_self`][gate]).
11//! ctl-core renders the help, so Clap's help writer is never needed. A plain
12//! `Cli::parse()` that skips ctl-core's entry point gets Clap's minimal
13//! output for `--help` and no `help` subcommand.
14//!
15//! [gate]: https://docs.rs/crate/clap_builder/4.6.6/source/src/builder/command.rs#4415
16//!
17//! ```
18//! use clap::Parser;
19//!
20//! #[derive(Parser)]
21//! #[command(version, about = "toy")]
22//! struct Cli {}
23//!
24//! ctl_core::parser::verify::<Cli>();
25//! ```
26
27#[cfg(feature = "help")]
28use std::ffi::OsString;
29
30#[cfg(feature = "help")]
31use clap::ArgMatches;
32use clap::{Arg, ArgAction, Command};
33
34#[cfg(feature = "help")]
35use crate::{ColorMode, OutputFormat};
36
37/// Output policy recovered with the authoritative Clap graph before a full
38/// parse succeeds.
39#[cfg(feature = "help")]
40#[derive(Clone, Copy, Debug, Eq, PartialEq)]
41pub(crate) struct ParsedOutput {
42    pub(crate) format: OutputFormat,
43    pub(crate) color: ColorMode,
44}
45
46/// Best-effort output policy for help and parse errors.
47///
48/// Clap still owns option placement, attached short values, domain option
49/// values, global propagation, and `--` semantics.
50#[cfg(feature = "help")]
51pub(crate) fn parsed_output<C: clap::CommandFactory>(raw: &[OsString]) -> ParsedOutput {
52    let command = C::command()
53        .arg_required_else_help(false)
54        .args_override_self(true)
55        .disable_help_flag(true)
56        .disable_version_flag(true)
57        .ignore_errors(true);
58    let Ok(matches) = command.try_get_matches_from(raw) else {
59        return ParsedOutput {
60            format: OutputFormat::Pretty,
61            color: ColorMode::Auto,
62        };
63    };
64    let format = matches
65        .try_get_one::<OutputFormat>("format")
66        .ok()
67        .flatten()
68        .copied()
69        .unwrap_or_default();
70    let color = matches
71        .try_get_one::<ColorMode>("color")
72        .ok()
73        .flatten()
74        .copied()
75        .unwrap_or_default();
76    let no_color = matches
77        .try_get_one::<bool>("no_color")
78        .ok()
79        .flatten()
80        .copied()
81        .unwrap_or(false);
82    ParsedOutput {
83        format,
84        color: crate::flags::resolve_color(color, no_color),
85    }
86}
87
88/// Whether this invocation asks for help: `-h` or `--help` where Clap reads
89/// a flag, or the `help` subcommand ctl-core declares.
90///
91/// Clap's own `help` subcommand stopped the parse before the parent's
92/// required arguments were checked. The probe keeps that: a subcommand
93/// lifts its parents' requirements, so `help` still answers when the parent
94/// needs an argument.
95#[cfg(feature = "help")]
96pub(crate) fn wants_help<C: clap::CommandFactory>(raw: &[OsString]) -> bool {
97    let declared = C::command();
98    let probe = lift_requirements(apply_defaults(declared.clone()));
99    match probe.try_get_matches_from(raw) {
100        Ok(matches) => names_help(&declared, &matches),
101        Err(error) => error.kind() == clap::error::ErrorKind::DisplayHelp,
102    }
103}
104
105/// `command` with every parent's requirements lifted once a subcommand is
106/// present.
107#[cfg(feature = "help")]
108fn lift_requirements(command: Command) -> Command {
109    command
110        .subcommand_negates_reqs(true)
111        .mut_subcommands(lift_requirements)
112}
113
114/// Whether `matches` select the `help` subcommand ctl-core declares, at any
115/// depth. A `help` subcommand the CLI declares itself is its own.
116#[cfg(feature = "help")]
117fn names_help(declared: &Command, matches: &ArgMatches) -> bool {
118    let Some((name, selected)) = matches.subcommand() else {
119        return false;
120    };
121    match declared.find_subcommand(name) {
122        Some(next) => names_help(next, selected),
123        None => name == "help",
124    }
125}
126
127/// Whether the command's Clap grammar rejects a bare invocation with help.
128#[cfg(feature = "app")]
129pub(crate) fn requires_input<C: clap::CommandFactory>() -> bool {
130    let command = C::command();
131    command.is_arg_required_else_help_set() || command.is_subcommand_required_set()
132}
133
134/// Apply ctl-core's parser rules to a Clap command and return it.
135///
136/// `-h/--help`, the `help` subcommand, and `-V/--version` stay on. Repeated
137/// flags take the last value (`args_override_self`), and
138/// [`chassis_warnings`](crate::flags::chassis_warnings) still
139/// reports the clash. [`App`] applies it to every run, so a CLI calls it only
140/// to parse a command itself.
141///
142/// ctl-core declares `-h/--help` and `help` on every command, as Clap does
143/// with its `help` feature. A CLI that parses the result itself calls
144/// [`help::try_emit`] first: the derive's `from_arg_matches` does not know
145/// the `help` subcommand.
146///
147/// # Panics
148///
149/// Panics if `command` disabled the help or version flag. ctl-core owns
150/// `-h`/`--help` and `-V`/`--version`, so the CLI never turns them off.
151#[cfg_attr(feature = "app", doc = "", doc = "[`App`]: crate::App")]
152#[cfg_attr(not(feature = "app"), doc = "", doc = "[`App`]: crate#cargo-features")]
153#[cfg_attr(
154    feature = "help",
155    doc = "",
156    doc = "[`help::try_emit`]: crate::help::try_emit"
157)]
158#[cfg_attr(
159    not(feature = "help"),
160    doc = "",
161    doc = "[`help::try_emit`]: crate#cargo-features"
162)]
163#[must_use]
164pub fn apply_defaults(command: Command) -> Command {
165    assert_help_enabled(&command);
166    with_help(command.args_override_self(true).disable_version_flag(false))
167}
168
169/// What Clap's own `help` subcommand says it does.
170const HELP_ABOUT: &str = "Print this message or the help of the given subcommand(s)";
171
172/// Whether `command` is a `help` subcommand ctl-core generated, by its name
173/// and the text Clap gives its own.
174#[cfg(feature = "surface-model")]
175pub(crate) fn is_generated_help(command: &Command) -> bool {
176    command.get_name() == "help"
177        && command
178            .get_about()
179            .is_some_and(|about| about.to_string() == HELP_ABOUT)
180}
181
182/// `command` with ctl-core's `-h/--help` flag and `help` subcommand, at every
183/// level Clap would add them, worded as Clap words them.
184///
185/// A command that already has a `help` argument or subcommand, or turned
186/// either off, keeps what it has, so a second call changes nothing.
187pub(crate) fn with_help(command: Command) -> Command {
188    with_help_below(command, false)
189}
190
191/// [`with_help`] for a command whose ancestors pass down a global argument
192/// with long help when `inherited_long` is true. Clap copies such an
193/// argument into each descendant, where it counts toward long help.
194fn with_help_below(mut command: Command, inherited_long: bool) -> Command {
195    let long = inherited_long || has_long_help(&command);
196    let passed_down = inherited_long
197        || command
198            .get_arguments()
199            .any(|arg| arg.is_global_set() && shows_long_help(arg));
200    let help_subcommand = (command.has_subcommands()
201        && !command.is_disable_help_subcommand_set()
202        && command.find_subcommand("help").is_none())
203    .then(|| help_subcommand(&command));
204    if !command.is_disable_help_flag_set()
205        && command.get_arguments().all(|arg| arg.get_id() != "help")
206    {
207        command = command.disable_help_flag(true).arg(help_flag(long));
208    }
209    command = command.mut_subcommands(|subcommand| with_help_below(subcommand, passed_down));
210    match help_subcommand {
211        Some(help) => command.disable_help_subcommand(true).subcommand(help),
212        None => command,
213    }
214}
215
216/// The `-h/--help` flag, with the text Clap gives its own.
217fn help_flag(long: bool) -> Arg {
218    let flag = Arg::new("help")
219        .short('h')
220        .long("help")
221        .action(ArgAction::Help);
222    if long {
223        flag.help("Print help (see more with '--help')")
224            .long_help("Print help (see a summary with '-h')")
225    } else {
226        flag.help("Print help")
227    }
228}
229
230/// The `help` subcommand, holding a copy of every subcommand name below
231/// `parent`, as Clap's own does when a command is built. `help status`
232/// parses, `help unknown` fails as an unknown subcommand, and completion
233/// offers the names.
234fn help_subcommand(parent: &Command) -> Command {
235    let help_help = Command::new("help")
236        .about(HELP_ABOUT)
237        .disable_help_flag(true)
238        .disable_version_flag(true);
239    Command::new("help")
240        .about(HELP_ABOUT)
241        .disable_help_flag(true)
242        .disable_version_flag(true)
243        .disable_help_subcommand(true)
244        .subcommands(parent.get_subcommands().map(copy_for_help))
245        .subcommand(help_help)
246}
247
248/// A subcommand's name, about, visibility, and children, without arguments.
249fn copy_for_help(command: &Command) -> Command {
250    let copy = Command::new(command.get_name().to_owned())
251        .hide(command.is_hide_set())
252        .disable_help_flag(true)
253        .disable_version_flag(true)
254        .disable_help_subcommand(true)
255        .subcommands(command.get_subcommands().map(copy_for_help));
256    match command.get_about() {
257        Some(about) => copy.about(about.clone()),
258        None => copy,
259    }
260}
261
262/// Whether `command` has long help of its own, by Clap 4.6.6's rule
263/// ([`Command::long_help_exists`][rule]). Subcommands do not count.
264///
265/// [rule]: https://docs.rs/crate/clap_builder/4.6.6/source/src/builder/command.rs#5160
266fn has_long_help(command: &Command) -> bool {
267    command.get_long_about().is_some()
268        || command.get_before_long_help().is_some()
269        || command.get_after_long_help().is_some()
270        || command.get_arguments().any(shows_long_help)
271}
272
273/// Whether `arg` makes its command's long help differ from the short one.
274fn shows_long_help(arg: &Arg) -> bool {
275    !arg.is_hide_set()
276        && (arg.get_long_help().is_some()
277            || arg.is_hide_long_help_set()
278            || arg.is_hide_short_help_set()
279            || (!arg.is_hide_possible_values_set()
280                && arg
281                    .get_possible_values()
282                    .iter()
283                    .any(|value| !value.is_hide_set() && value.get_help().is_some())))
284}
285
286/// Check a CLI's parser: Clap's recommended `Command::debug_assert`, and that
287/// help and version stay on.
288///
289/// Call it from a unit test, so a mistake in the Clap types fails the test
290/// suite and not a user's first run.
291///
292/// # Panics
293///
294/// Panics when Clap's `debug_assert` finds an invalid command definition, or
295/// when the command disables `-h/--help` or `-V/--version`.
296pub fn verify<C: clap::CommandFactory>() {
297    C::command().debug_assert();
298    assert_help_enabled(&C::command());
299}
300
301/// The help-flag contract for one command.
302///
303/// # Panics
304///
305/// Panics if `disable_help_flag` or `disable_version_flag` is set.
306pub(crate) fn assert_help_enabled(command: &Command) {
307    assert!(
308        !command.is_disable_help_flag_set(),
309        "disable_help_flag is forbidden; ctl-core owns -h/--help"
310    );
311    assert!(
312        !command.is_disable_version_flag_set(),
313        "disable_version_flag is forbidden; ctl-core owns -V/--version"
314    );
315}
316
317#[cfg(test)]
318mod tests {
319    use clap::{Command, CommandFactory, Parser};
320
321    use super::{apply_defaults, assert_help_enabled};
322
323    #[derive(Parser)]
324    #[command(version, about = "toy", arg_required_else_help = true)]
325    struct Toy {
326        #[command(subcommand)]
327        command: ToyCmd,
328    }
329
330    #[derive(clap::Subcommand)]
331    enum ToyCmd {
332        Status,
333    }
334
335    #[test]
336    fn verify_toy() {
337        super::verify::<Toy>();
338    }
339
340    #[test]
341    fn derive_keeps_help_and_version() {
342        let mut command = apply_defaults(Toy::command());
343        assert!(command.is_args_override_self());
344        command.build();
345        let flags = command
346            .get_arguments()
347            .filter_map(|arg| arg.get_short().zip(arg.get_long()))
348            .collect::<Vec<_>>();
349        assert!(flags.contains(&('h', "help")), "{flags:?}");
350        assert!(flags.contains(&('V', "version")), "{flags:?}");
351        let status = command.find_subcommand("status").map(|status| {
352            status
353                .get_arguments()
354                .any(|arg| arg.get_short() == Some('h'))
355        });
356        assert_eq!(status, Some(true));
357        assert!(command.find_subcommand("help").is_some());
358    }
359
360    #[test]
361    fn defaults_apply_once() {
362        let once = apply_defaults(Toy::command());
363        let twice = super::with_help(once.clone());
364        assert_eq!(once.get_arguments().count(), twice.get_arguments().count());
365        assert_eq!(
366            once.get_subcommands().count(),
367            twice.get_subcommands().count()
368        );
369    }
370
371    #[test]
372    fn preserves_bare_invocation_policy() {
373        let command = apply_defaults(Toy::command().arg_required_else_help(false));
374        assert!(!command.is_arg_required_else_help_set());
375    }
376
377    #[test]
378    #[should_panic(expected = "disable_help_flag is forbidden")]
379    fn rejects_disabled_help() {
380        let command = Command::new("x").disable_help_flag(true);
381        assert_help_enabled(&command);
382    }
383}