1use super::spec::{ArgSpec, CommandSpec};
24use super::{join_lines, paragraphs, style, wrap_indented};
25use rich::measure::Measurement;
26use rich::{Console, ConsoleOptions, Renderable, Segment, Style, Text};
27
28pub const STACK_BELOW: usize = 60;
31const INDENT: usize = 2;
32const GAP: usize = 2;
33const STACK_INDENT: usize = 6;
34const USAGE_LABEL: &str = "Usage: ";
35
36#[derive(Clone, Debug)]
48pub struct HelpView {
49 spec: CommandSpec,
50 long: bool,
51 command_path: Option<String>,
52}
53
54struct Entry {
56 names: Text,
57 help: Text,
58 choices: Vec<Text>,
59}
60
61enum Mode {
62 Columns(usize),
64 Stacked,
65}
66
67impl HelpView {
68 pub fn new(spec: &CommandSpec) -> Self {
69 HelpView {
70 spec: spec.clone(),
71 long: false,
72 command_path: None,
73 }
74 }
75
76 pub fn for_path(root: &CommandSpec, path: &[&str]) -> Option<Self> {
79 let mut spec = root;
80 let mut shown = root.display_name().to_string();
81 for name in path {
82 spec = spec.find_subcommand(name)?;
83 shown.push(' ');
84 shown.push_str(&spec.name);
85 }
86 Some(HelpView::new(spec).command_path(shown))
87 }
88
89 pub fn long(mut self, long: bool) -> Self {
92 self.long = long;
93 self
94 }
95
96 pub fn command_path(mut self, path: impl Into<String>) -> Self {
98 self.command_path = Some(path.into());
99 self
100 }
101
102 fn usage_lines(&self) -> Vec<String> {
103 match &self.command_path {
104 Some(path) => self.spec.usage_lines_as(path),
105 None => self.spec.usage_lines(),
106 }
107 }
108
109 fn about(&self) -> &str {
110 match (&self.spec.long_about, self.long) {
111 (Some(long), true) => long,
112 _ => &self.spec.about,
113 }
114 }
115
116 fn arg_entry(&self, c: &Console, arg: &ArgSpec, indent_long: bool) -> Entry {
119 let option = style(c, "help.option");
120 let metavar = style(c, "help.metavar");
121 let hint = style(c, "help.hint");
122 let mut names = Text::new("");
123 if arg.positional {
124 append(&mut names, &arg.names(), &metavar);
125 } else {
126 if indent_long && arg.short.is_none() {
127 names.append(" ", None);
128 }
129 for (i, switch) in arg.switches().iter().enumerate() {
130 if i > 0 {
131 names.append(", ", None);
132 }
133 append(&mut names, switch, &option);
134 }
135 if let Some(value) = arg.metavar() {
136 names.append(" ", None);
137 append(&mut names, &value, &metavar);
138 }
139 }
140 let text = match (&arg.long_help, self.long) {
141 (Some(long), true) => long.as_str(),
142 _ => arg.help.as_str(),
143 };
144 let mut help = Text::new("");
145 append(&mut help, text.trim_end(), &style(c, "help.description"));
146 let mut hints = Vec::new();
147 if let Some(default) = &arg.default {
148 hints.push(format!("[default: {default}]"));
149 }
150 if let Some(env) = &arg.env {
151 hints.push(format!("[env: {env}]"));
152 }
153 if let Some(key) = &arg.config_key {
154 hints.push(format!("[config: {key}]"));
155 }
156 let choices = arg.choice_list();
157 let described = choices.iter().any(|choice| !choice.help.is_empty());
158 if !choices.is_empty() && !described {
159 let values: Vec<&str> = choices.iter().map(|c| c.value.as_str()).collect();
160 hints.push(format!("[possible values: {}]", values.join(", ")));
161 }
162 for hint_text in hints {
163 if !help.is_empty() {
164 help.append(" ", None);
165 }
166 append(&mut help, &hint_text, &hint);
167 }
168 let mut list = Vec::new();
169 if described {
170 list.push(Text::styled("Possible values:", hint.clone()));
171 for choice in choices {
172 let mut line = Text::new("- ");
173 append(&mut line, &choice.value, &metavar);
174 if !choice.help.is_empty() {
175 line.append(": ", None);
176 append(&mut line, &choice.help, &style(c, "help.description"));
177 }
178 list.push(line);
179 }
180 }
181 Entry {
182 names,
183 help,
184 choices: list,
185 }
186 }
187
188 fn command_entry(&self, c: &Console, command: &CommandSpec) -> Entry {
189 let mut names = Text::new("");
190 append(&mut names, &command.name, &style(c, "help.command"));
191 let mut help = Text::new("");
192 let about = paragraphs(&command.about)
193 .into_iter()
194 .next()
195 .unwrap_or_default();
196 append(&mut help, &about, &style(c, "help.description"));
197 if !command.aliases.is_empty() {
198 if !help.is_empty() {
199 help.append(" ", None);
200 }
201 let aliases = format!("[aliases: {}]", command.aliases.join(", "));
202 append(&mut help, &aliases, &style(c, "help.hint"));
203 }
204 Entry {
205 names,
206 help,
207 choices: Vec::new(),
208 }
209 }
210
211 fn groups(&self, c: &Console) -> Vec<(String, Option<String>, Vec<Entry>)> {
213 let indent_long = self
214 .spec
215 .visible_args()
216 .any(|a| !a.positional && a.short.is_some());
217 let mut out: Vec<(String, Option<String>, Vec<Entry>)> = self
218 .spec
219 .groups()
220 .into_iter()
221 .map(|(title, args)| {
222 let note = self.spec.note_for(&title).map(str::to_string);
223 let entries = args
224 .into_iter()
225 .map(|a| self.arg_entry(c, a, indent_long))
226 .collect();
227 (title, note, entries)
228 })
229 .collect();
230 let commands: Vec<Entry> = self
231 .spec
232 .visible_subcommands()
233 .map(|command| self.command_entry(c, command))
234 .collect();
235 if !commands.is_empty() {
236 let title = self
237 .spec
238 .subcommand_heading
239 .clone()
240 .unwrap_or_else(|| "Commands".into());
241 let note = self.spec.note_for(&title).map(str::to_string);
242 out.push((title, note, commands));
243 }
244 out
245 }
246
247 fn natural_columns(groups: &[(String, Option<String>, Vec<Entry>)]) -> (usize, usize) {
250 let entries = groups.iter().flat_map(|(_, _, entries)| entries);
251 let names = entries
252 .clone()
253 .map(|e| e.names.cell_len())
254 .max()
255 .unwrap_or(0);
256 let help = entries
257 .flat_map(|e| std::iter::once(&e.help).chain(&e.choices))
258 .map(|t| t.measurement().1)
259 .max()
260 .unwrap_or(0);
261 (INDENT + names + GAP + help, names)
262 }
263
264 fn natural_prose(&self, groups: &[(String, Option<String>, Vec<Entry>)]) -> usize {
266 let widest = |text: &str| text.lines().map(rich::cells::cell_len).max().unwrap_or(0);
267 let mut width = self
268 .usage_lines()
269 .iter()
270 .map(|line| USAGE_LABEL.len() + widest(line))
271 .max()
272 .unwrap_or(0);
273 width = width.max(widest(self.about()));
274 for (title, note, _) in groups {
275 width = width.max(heading_text(title).len());
276 if let Some(note) = note {
277 width = width.max(INDENT + widest(note));
278 }
279 }
280 for example in &self.spec.examples {
281 width = width
282 .max(INDENT + widest(&example.description))
283 .max(2 * INDENT + 2 + widest(&example.command));
284 }
285 for section in &self.spec.sections {
286 let indent = if section.title.is_empty() { 0 } else { INDENT };
287 width = width
288 .max(heading_text(§ion.title).len())
289 .max(indent + widest(§ion.body));
290 }
291 width
292 }
293
294 fn mode(width: usize, natural: usize, names: usize) -> Mode {
295 if width >= natural {
296 Mode::Columns(names)
297 } else if width >= STACK_BELOW {
298 Mode::Columns(names.min(width * 2 / 5))
299 } else {
300 Mode::Stacked
301 }
302 }
303
304 fn render_entry(
305 &self,
306 c: &Console,
307 entry: &Entry,
308 mode: &Mode,
309 width: usize,
310 ) -> Vec<Vec<Segment>> {
311 let theme = c.theme();
312 let plain = Style::new();
313 let mut rows = Vec::new();
314 let (help_indent, names_inline) = match mode {
315 Mode::Columns(col) => (INDENT + col + GAP, entry.names.cell_len() <= *col),
316 Mode::Stacked => (STACK_INDENT, false),
317 };
318 let mut help_lines = if entry.help.is_empty() {
319 Vec::new()
320 } else {
321 wrap_indented(c, &entry.help, help_indent, width)
322 };
323 for choice in &entry.choices {
324 let inner = width.saturating_sub(help_indent + 2).max(1);
325 let lines = choice.render_lines(theme, &plain, Some(inner));
326 for (i, line) in lines.into_iter().enumerate() {
327 let pad = help_indent + if i == 0 { 0 } else { 2 };
328 let mut row = vec![Segment::new(" ".repeat(pad), None)];
329 row.extend(line);
330 help_lines.push(row);
331 }
332 }
333 if names_inline && !help_lines.is_empty() {
334 let mut first = vec![Segment::new(" ".repeat(INDENT), None)];
335 first.extend(entry.names.render(theme, &plain));
336 let used = INDENT + entry.names.cell_len();
337 let mut help = help_lines.remove(0);
340 if let Some(lead) = help.first_mut() {
341 lead.text = " ".repeat(help_indent - used);
342 }
343 first.extend(help);
344 rows.push(first);
345 } else {
346 rows.extend(wrap_indented(c, &entry.names, INDENT, width));
347 }
348 rows.extend(help_lines);
349 rows
350 }
351}
352
353fn append(text: &mut Text, value: &str, style: &Style) {
354 if style.is_null() {
355 text.append(value, None);
356 } else {
357 text.append(value, Some(style.clone().into()));
358 }
359}
360
361fn heading_text(title: &str) -> String {
362 if title.ends_with(':') {
363 title.to_string()
364 } else {
365 format!("{title}:")
366 }
367}
368
369impl Renderable for HelpView {
370 fn rich_render(&self, c: &Console, o: &ConsoleOptions) -> Vec<Segment> {
371 let width = o.max_width;
372 if width == 0 {
373 return Vec::new();
374 }
375 let groups = self.groups(c);
376 let (natural, names) = Self::natural_columns(&groups);
377 let mode = Self::mode(width, natural, names);
378 let heading = style(c, "help.heading");
379 let heading_row =
380 |title: &str| vec![Segment::new(heading_text(title), Some(heading.clone()))];
381 let mut rows: Vec<Vec<Segment>> = Vec::new();
382
383 let usage_style = style(c, "help.usage");
385 let command = self
386 .command_path
387 .clone()
388 .unwrap_or_else(|| self.spec.display_name().to_string());
389 for (i, line) in self.usage_lines().iter().enumerate() {
390 let mut text = Text::new("");
391 match line.strip_prefix(&command) {
392 Some(rest) => {
393 append(&mut text, &command, &usage_style);
394 text.append(rest, None);
395 }
396 None => text.append(line, None),
397 }
398 let mut lines = wrap_indented(c, &text, USAGE_LABEL.len(), width);
399 if i == 0 {
400 if let Some(first) = lines.first_mut() {
401 first[0] = Segment::new(USAGE_LABEL.trim_end(), Some(heading.clone()));
402 first.insert(1, Segment::new(" ", None));
403 }
404 }
405 rows.extend(lines);
406 }
407
408 let about = paragraphs(self.about());
409 if !about.is_empty() {
410 for paragraph in about {
411 rows.push(Vec::new());
412 rows.extend(wrap_indented(c, &Text::new(paragraph), 0, width));
413 }
414 }
415
416 for (title, note, entries) in &groups {
417 rows.push(Vec::new());
418 rows.push(heading_row(title));
419 if let Some(note) = note {
420 rows.extend(wrap_indented(c, &Text::new(note.as_str()), INDENT, width));
421 }
422 for (i, entry) in entries.iter().enumerate() {
423 if self.long && i > 0 {
424 rows.push(Vec::new());
425 }
426 rows.extend(self.render_entry(c, entry, &mode, width));
427 }
428 }
429
430 if !self.spec.examples.is_empty() {
431 rows.push(Vec::new());
432 rows.push(heading_row("Examples"));
433 let example = style(c, "help.example");
434 for item in &self.spec.examples {
435 let mut indent = INDENT;
436 if !item.description.is_empty() {
437 rows.extend(wrap_indented(
438 c,
439 &Text::new(item.description.as_str()),
440 INDENT,
441 width,
442 ));
443 indent += INDENT;
444 }
445 let mut command = Text::new("");
446 append(&mut command, &format!("$ {}", item.command), &example);
447 rows.extend(wrap_indented(c, &command, indent, width));
448 }
449 }
450
451 for section in &self.spec.sections {
452 let indent = if section.title.is_empty() {
453 0
454 } else {
455 rows.push(Vec::new());
456 rows.push(heading_row(§ion.title));
457 INDENT
458 };
459 for (i, paragraph) in paragraphs(§ion.body).into_iter().enumerate() {
460 if i > 0 || section.title.is_empty() {
461 rows.push(Vec::new());
462 }
463 rows.extend(wrap_indented(c, &Text::new(paragraph), indent, width));
464 }
465 }
466
467 while rows.first().is_some_and(Vec::is_empty) {
469 rows.remove(0);
470 }
471 if let Some(height) = o.height {
472 rows.truncate(height);
473 }
474 join_lines(rows)
475 }
476
477 fn measure(&self, c: &Console, o: &ConsoleOptions) -> Measurement {
478 let groups = self.groups(c);
479 let (columns, names) = Self::natural_columns(&groups);
480 let natural = columns.max(self.natural_prose(&groups));
481 let maximum = natural.min(o.max_width);
482 let word = |text: &str| {
483 text.split_whitespace()
484 .map(rich::cells::cell_len)
485 .max()
486 .unwrap_or(0)
487 };
488 let longest_word = groups
489 .iter()
490 .flat_map(|(_, _, entries)| entries)
491 .map(|e| word(e.help.plain()))
492 .chain(std::iter::once(word(self.about())))
493 .max()
494 .unwrap_or(0);
495 let minimum = (INDENT + names).max(STACK_INDENT + longest_word);
496 Measurement::new(minimum.min(maximum), maximum)
497 }
498}
499
500#[cfg(test)]
501mod tests {
502 use super::*;
503 use crate::cli_doc::{ArgSpec, ValueHint};
504
505 fn render(view: &HelpView, width: usize) -> String {
506 Console::builder()
507 .width(width)
508 .build()
509 .render_to_string(view)
510 }
511
512 #[test]
513 fn entries_without_help_render_names_only() {
514 let spec = CommandSpec::new("x").arg(ArgSpec::flag("quiet"));
515 assert_eq!(
516 render(&HelpView::new(&spec), 40),
517 "Usage: x [OPTIONS]\n\nOptions:\n --quiet"
518 );
519 }
520
521 #[test]
522 fn value_hint_none_is_a_flag() {
523 assert!(!ArgSpec::flag("x").takes_value());
524 assert!(ArgSpec::flag("x").value(ValueHint::File).takes_value());
525 }
526}