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