1use std::io::{self, Write};
4
5use clap::{Command, CommandFactory};
6
7use crate::color::ColorMode;
8use crate::document::{Document, Section, Table, Text};
9use crate::render::RenderOptions;
10
11const NARROW_HELP_WIDTH: u16 = 64;
12
13pub 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
24pub 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
34pub(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
54pub(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#[must_use]
101pub fn document(mut command: Command) -> Document {
102 command.build();
103 let usage = command.render_usage().to_string();
104 let mut output = Document::new().heading(usage.trim().to_owned());
105 if let Some(about) = command.get_about() {
106 output = output.paragraph(about.to_string());
107 }
108
109 let commands = command
110 .get_subcommands()
111 .filter(|subcommand| !subcommand.is_hide_set())
112 .fold(Table::plain().token_column(0), |table, subcommand| {
113 table.row([
114 Text::plain(subcommand.get_name()),
115 Text::plain(
116 subcommand
117 .get_about()
118 .map_or_else(String::new, ToString::to_string),
119 ),
120 ])
121 });
122 if !commands.is_empty() {
123 output = output.section(Section::new(
124 "Commands",
125 Document::new().table(commands.stacked_below(NARROW_HELP_WIDTH, 1)),
126 ));
127 }
128
129 let positionals = command
130 .get_positionals()
131 .filter(|arg| !arg.is_hide_set())
132 .fold(Table::plain(), |table, arg| {
133 table.row([
134 Text::new(),
135 Text::new().value(value_label(arg)),
136 Text::new(),
137 description(arg),
138 ])
139 });
140 if !positionals.is_empty() {
141 output = output.section(Section::new(
142 "Arguments",
143 Document::new().table(positionals.stacked_below(NARROW_HELP_WIDTH, 3)),
144 ));
145 }
146
147 let mut headings = Vec::<String>::new();
148 for arg in command
149 .get_arguments()
150 .filter(|arg| !arg.is_positional() && !arg.is_hide_set() && arg.get_id().as_str() != "help")
151 {
152 let heading = arg
153 .get_help_heading()
154 .map_or_else(|| "Options".to_owned(), ToString::to_string);
155 if !headings.contains(&heading) {
156 headings.push(heading);
157 }
158 }
159 if command
160 .get_arguments()
161 .any(|arg| arg.get_id().as_str() == "help")
162 {
163 headings.push("Help".into());
164 }
165 for heading in headings {
166 let rows = command
167 .get_arguments()
168 .filter(|arg| {
169 if heading == "Help" {
170 return arg.get_id().as_str() == "help";
171 }
172 !arg.is_positional()
173 && !arg.is_hide_set()
174 && arg.get_id().as_str() != "help"
175 && arg.get_help_heading().map_or("Options", |value| value) == heading
176 })
177 .fold(Table::plain(), |table, arg| {
178 table.row([
179 arg.get_short()
180 .map_or_else(Text::new, |value| Text::new().token(format!("-{value}"))),
181 arg.get_long()
182 .map_or_else(Text::new, |value| Text::new().token(format!("--{value}"))),
183 Text::new().value(value_label(arg)),
184 description(arg),
185 ])
186 });
187 if !rows.is_empty() {
188 output = output.section(Section::new(
189 heading,
190 Document::new().table(rows.stacked_below(NARROW_HELP_WIDTH, 3)),
191 ));
192 }
193 }
194 output
195}
196
197fn value_label(arg: &clap::Arg) -> String {
198 if matches!(
199 arg.get_action(),
200 clap::ArgAction::SetTrue
201 | clap::ArgAction::SetFalse
202 | clap::ArgAction::Help
203 | clap::ArgAction::Version
204 ) {
205 return String::new();
206 }
207 let names = arg
208 .get_value_names()
209 .map(|names| {
210 names
211 .iter()
212 .map(ToString::to_string)
213 .collect::<Vec<_>>()
214 .join(" ")
215 })
216 .unwrap_or_default();
217 let choices = arg
218 .get_possible_values()
219 .into_iter()
220 .filter(|value| !value.is_hide_set())
221 .map(|value| value.get_name().to_owned())
222 .collect::<Vec<_>>();
223 if choices.is_empty() {
224 names
225 } else {
226 format!("[{}]", choices.join("|"))
227 }
228}
229
230fn description(arg: &clap::Arg) -> Text {
231 let description = arg.get_help().map_or_else(String::new, ToString::to_string);
232 let defaults = arg
233 .get_default_values()
234 .iter()
235 .map(|value| value.to_string_lossy())
236 .collect::<Vec<_>>();
237 if defaults.is_empty()
238 || matches!(
239 arg.get_action(),
240 clap::ArgAction::SetTrue | clap::ArgAction::SetFalse
241 )
242 {
243 return Text::plain(description);
244 }
245 let separator = if description.is_empty() { "" } else { " " };
246 Text::plain(description)
247 .then(separator)
248 .muted(format!("[default: {}]", defaults.join(", ")))
249}
250
251#[cfg(test)]
252mod tests {
253 use clap::{CommandFactory, Parser};
254
255 use super::{document, help_command, try_emit_from};
256 use crate::color::ColorMode;
257 use crate::flags::{DryRunArgs, OutputArgs};
258 use crate::render::RenderOptions;
259
260 #[derive(Parser)]
261 #[command(version, about = "toy ctl", arg_required_else_help = true)]
262 struct Toy {
263 #[command(flatten)]
264 output: OutputArgs,
265 #[command(flatten)]
266 dry: DryRunArgs,
267 #[command(subcommand)]
268 command: ToyCmd,
269 }
270
271 #[derive(clap::Subcommand)]
272 enum ToyCmd {
273 Status(StatusArgs),
275 Group {
277 #[command(subcommand)]
278 command: GroupCmd,
279 },
280 }
281
282 #[derive(clap::Subcommand)]
283 enum GroupCmd {
284 Show,
286 }
287
288 #[derive(clap::Args)]
289 struct StatusArgs {
290 #[arg(short = 'm', long, allow_hyphen_values = true)]
292 message: Option<String>,
293 }
294
295 #[test]
296 fn document_lists_commands_and_flags() {
297 let text = document(Toy::command()).render(RenderOptions::new(ColorMode::Never).width(80));
298 assert!(text.contains("Commands"));
299 assert!(text.contains("status"));
300 assert!(text.contains("--dry-run"));
301 assert!(text.contains("--format"));
302 assert!(text.contains("--no-color"));
303 }
304
305 #[test]
306 fn colorless_help_has_no_ansi() {
307 let text = document(Toy::command()).render(RenderOptions::new(ColorMode::Never).width(80));
308 assert!(!text.contains('\u{1b}'));
309 }
310
311 #[test]
312 fn help_subcommand_selects_the_requested_command() {
313 let args = ["toy", "help", "status"].map(String::from);
314 assert_eq!(help_command::<Toy>(&args).get_name(), "status");
315 }
316
317 #[test]
318 fn subcommand_help_keeps_parent_usage_and_globals() {
319 let command = help_command::<Toy>(&["toy", "status", "--help"].map(String::from));
320 assert_eq!(command.get_bin_name(), Some("ctl-core status"));
321 assert!(command.get_arguments().any(|arg| arg.get_id() == "format"));
322 }
323
324 #[test]
325 fn nested_help_subcommand_selects_the_requested_command() {
326 let args = ["toy", "group", "help", "show"].map(String::from);
327 assert_eq!(help_command::<Toy>(&args).get_name(), "show");
328 }
329
330 #[test]
331 fn try_emit_skips_without_help() {
332 let args = ["toy", "status"]
333 .into_iter()
334 .map(String::from)
335 .collect::<Vec<_>>();
336 assert!(!try_emit_from::<Toy>(&args).unwrap());
337 }
338
339 #[test]
340 fn try_emit_ignores_help_used_as_a_domain_value() {
341 let args = ["toy", "status", "-m", "--help"]
342 .into_iter()
343 .map(String::from)
344 .collect::<Vec<_>>();
345 assert!(!try_emit_from::<Toy>(&args).unwrap());
346 }
347
348 #[test]
349 fn try_emit_ignores_help_after_separator() {
350 let args = ["toy", "status", "--", "--help"]
351 .into_iter()
352 .map(String::from)
353 .collect::<Vec<_>>();
354 assert!(!try_emit_from::<Toy>(&args).unwrap());
355 }
356
357 #[test]
358 fn try_emit_does_not_claim_bare_invocation() {
359 let args = ["toy"].map(String::from);
360 assert!(!try_emit_from::<Toy>(&args).unwrap());
361 }
362}