1use 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
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 =
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 Status(StatusArgs),
276 Group {
278 #[command(subcommand)]
279 command: GroupCmd,
280 },
281 }
282
283 #[derive(clap::Subcommand)]
284 enum GroupCmd {
285 Show,
287 }
288
289 #[derive(clap::Args)]
290 struct StatusArgs {
291 #[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}