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