Oregami
Repositories/oxedyne/fe2o3

oxedyne/fe2o3/fe2o3_syntax/src/help.rs

22.8 KiB, 75 runs

created by r1870400018:1049, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1//! 2026-09-23: rewritten for command lines as well as REPLs. Commands are grouped under their
2//! category in the order they were added, a command has a page of its own and a syntax can carry
3//! named prose topics. Text is wrapped to a width, and colour is used only when standard output
4//! is a terminal that wants it.
5use crate::{
6 arg::Arg,
7 cmd::Cmd,
8 core::Syntax,
9 val::Val,
10};
11
12use oxedyne_fe2o3_core::prelude::*;
13use oxedyne_fe2o3_stds::chars::Term;
14
15use std::{
16 ffi::OsString,
17 io::IsTerminal,
18};
19
20
21/// A named page of prose, reached with `help <name>`.
22#[derive(Clone, Debug, Default, PartialEq)]
23pub struct Topic {
24 pub name: String,
25 pub help: String, // one line, the page's title
26 pub text: String, // paragraphs separated by blank lines
27}
28
29impl Topic {
30 pub fn new<S1: Into<String>, S2: Into<String>, S3: Into<String>>(
31 name: S1,
32 help: S2,
33 text: S3,
34 )
35 -> Self
36 {
37 Self {
38 name: name.into(),
39 help: help.into(),
40 text: text.into(),
41 }
42 }
43}
44
45/// Which help a reader asked for.
46#[derive(Clone, Debug, Eq, PartialEq)]
47pub enum Page {
48 Summary, // the command table
49 All, // the table, every command page and every topic
50 Command(String),
51 Topic(String),
52}
53
54#[derive(Clone, Debug)]
55pub struct HelpDisplayConfig {
56 pub colour: bool,
57 pub width: usize,
58 pub indent: usize, // where names start
59 pub table_col: usize, // where a command's line starts in the table
60 pub desc_col: usize, // where a value's or option's text starts
61 pub cmd_effect: String,
62 pub val_effect: String,
63 pub arg_effect: String,
64 pub about_effect: String,
65}
66
67impl Default for HelpDisplayConfig {
68 fn default() -> Self {
69 Self::for_stdout()
70 }
71}
72
73impl HelpDisplayConfig {
74
75 pub const WIDTH_DEFAULT: usize = 80;
76 pub const WIDTH_MIN: usize = 60;
77 pub const WIDTH_MAX: usize = 100;
78
79 /// Help without colour, at the given width, for text that is not bound for a terminal.
80 pub fn plain(width: usize) -> Self {
81 Self {
82 colour: false,
83 width,
84 indent: 5,
85 table_col: 17,
86 desc_col: 26,
87 cmd_effect: Term::SET_BRIGHT_FORE_YELLOW.to_string() + Term::BOLD,
88 val_effect: Term::SET_BRIGHT_FORE_MAGENTA.to_string() + Term::BOLD,
89 arg_effect: Term::SET_BRIGHT_FORE_GREEN.to_string() + Term::BOLD,
90 about_effect: Term::ITALIC.to_string() + Term::BOLD + Term::SET_BRIGHT_FORE_RED,
91 }
92 }
93
94 /// Help as standard output should have it: coloured only on a terminal that wants colour,
95 /// and as wide as `COLUMNS` says within bounds.
96 pub fn for_stdout() -> Self {
97 let mut cfg = Self::plain(Self::width_for(std::env::var("COLUMNS").ok()));
98 cfg.colour = Self::colour_for(
99 std::io::stdout().is_terminal(),
100 std::env::var_os("NO_COLOR"),
101 std::env::var_os("TERM"),
102 );
103 cfg
104 }
105
106 /// Should help be coloured? Not when the output is not a terminal, not when `NO_COLOR` is
107 /// set to anything (<https://no-color.org>), and not on a `dumb` terminal.
108 pub fn colour_for(
109 is_tty: bool,
110 no_color: Option<OsString>,
111 term: Option<OsString>,
112 )
113 -> bool
114 {
115 if !is_tty {
116 return false;
117 }
118 if let Some(v) = no_color {
119 if !v.is_empty() {
120 return false;
121 }
122 }
123 match term {
124 Some(t) => t != "dumb",
125 None => true,
126 }
127 }
128
129 /// The width help is wrapped to, given the value of `COLUMNS` if any.
130 pub fn width_for(columns: Option<String>) -> usize {
131 match columns.and_then(|c| c.trim().parse::<usize>().ok()) {
132 Some(w) => w.clamp(Self::WIDTH_MIN, Self::WIDTH_MAX),
133 None => Self::WIDTH_DEFAULT,
134 }
135 }
136}
137
138#[derive(Clone, Debug, Default)]
139pub struct Help {
140 pub cfg: HelpDisplayConfig,
141}
142
143impl Help {
144
145 pub fn new(cfg: HelpDisplayConfig) -> Self {
146 Self {
147 cfg,
148 }
149 }
150
151 // ┌───────────────────────┐
152 // │ PAGES │
153 // └───────────────────────┘
154
155 pub fn page(&self, syntax: &Syntax, page: &Page) -> Outcome<Vec<String>> {
156 match page {
157 Page::Summary => Ok(self.summary(syntax)),
158 Page::All => Ok(self.all(syntax)),
159 Page::Command(name) => self.command_page(syntax, name),
160 Page::Topic(name) => self.topic_page(syntax, name),
161 }
162 }
163
164 /// The first line of `--version`.
165 pub fn version_line(syntax: &Syntax) -> String {
166 fmt!("{} {}", syntax.config().name, syntax.config().ver)
167 }
168
169 /// The top page: what the program is, how it is called, its commands by category and its
170 /// topics.
171 pub fn summary(&self, syntax: &Syntax) -> Vec<String> {
172 let cfg = syntax.config();
173 let mut lines = Vec::new();
174 lines.append(&mut self.title_lines(&Self::version_line(syntax), cfg.about.as_deref()));
175 lines.push(String::new());
176 lines.push(fmt!("USAGE:"));
177 lines.append(&mut self.synopsis(&cfg.name, &self.generic_synopsis(syntax)));
178 if cfg.one_cmd {
179 let first = fmt!("{} help <command | topic>", cfg.name);
180 let second = fmt!("{} <command> --help", cfg.name);
181 let pad = " ".repeat(std::cmp::max(2, 40usize.saturating_sub(first.chars().count())));
182 let joined = fmt!("{}{}{}", first, pad, second);
183 if self.cfg.indent + joined.chars().count() <= self.cfg.width {
184 lines.push(fmt!("{}{}", self.ind(), joined));
185 } else {
186 lines.push(fmt!("{}{}", self.ind(), first));
187 lines.push(fmt!("{}{}", self.ind(), second));
188 }
189 }
190 lines.push(String::new());
191 let margs = syntax.args_in_order();
192 if !margs.is_empty() {
193 lines.push(fmt!("OPTIONS:"));
194 for arg in margs {
195 lines.append(&mut self.arg_row(arg, self.cfg.indent));
196 }
197 }
198 for (cat, cmds) in Self::categories(syntax) {
199 lines.push(Self::heading(&cat));
200 for cmd in cmds {
201 lines.append(&mut self.row(
202 self.cfg.indent,
203 &[(self.cfg.cmd_effect.as_str(), cmd.config().name.clone())],
204 self.cfg.table_col,
205 &cmd.config().help.as_deref().map(Self::normalise).unwrap_or_default(),
206 ));
207 }
208 }
209 if !cfg.topics.is_empty() {
210 lines.push(String::new());
211 lines.push(fmt!("TOPICS:"));
212 let names = cfg.topics.iter().map(|t| t.name.clone()).collect::<Vec<_>>();
213 for line in Self::wrap_words(&names, self.text_width(self.cfg.indent), " ") {
214 lines.push(fmt!("{}{}", self.ind(), line));
215 }
216 }
217 if let Some(footer) = &cfg.footer {
218 lines.push(String::new());
219 lines.append(&mut self.prose(footer));
220 }
221 lines
222 }
223
224 /// A command's own page: its synopsis, values, options, prose and pointers.
225 pub fn command_page(&self, syntax: &Syntax, name: &str) -> Outcome<Vec<String>> {
226 let cmd = match syntax.get_cmd(name) {
227 Some(cmd) => cmd,
228 None => return Err(err!(
229 "There is no command '{}' in '{}' to show help for.",
230 name, syntax.config().name;
231 Input, Missing)),
232 };
233 let sname = &syntax.config().name;
234 let ccfg = cmd.config();
235 let mut lines = Vec::new();
236 let title = fmt!("{} {}", sname, ccfg.name);
237 lines.append(&mut self.title_lines(
238 &title, ccfg.help.as_deref().map(Self::lowered).as_deref()));
239 lines.push(String::new());
240 lines.push(fmt!("USAGE:"));
241 lines.append(&mut self.synopsis(&title, &Self::cmd_synopsis(cmd)));
242 lines.push(String::new());
243 if !ccfg.vals.is_empty() || ccfg.rest.is_some() {
244 lines.push(fmt!("VALUES:"));
245 for val in &ccfg.vals {
246 lines.append(&mut self.val_row(val, "", self.cfg.indent));
247 }
248 if let Some(rest) = &ccfg.rest {
249 lines.append(&mut self.val_row(rest, "-- ", self.cfg.indent));
250 }
251 }
252 let args = cmd.args_in_order();
253 if !args.is_empty() {
254 lines.push(fmt!("OPTIONS:"));
255 for arg in args {
256 lines.append(&mut self.arg_row(arg, self.cfg.indent));
257 }
258 }
259 if let Some(detail) = &ccfg.detail {
260 if lines.last().map_or(false, |l| !l.is_empty()) {
261 lines.push(String::new());
262 }
263 lines.append(&mut self.prose(detail));
264 }
265 if !ccfg.see.is_empty() {
266 if lines.last().map_or(false, |l| !l.is_empty()) {
267 lines.push(String::new());
268 }
269 let refs = ccfg.see.iter()
270 .map(|s| fmt!("{} help {}", sname, s))
271 .collect::<Vec<_>>()
272 .join(" ");
273 lines.push(fmt!("SEE ALSO: {}", refs));
274 }
275 while lines.last().map_or(false, |l| l.is_empty()) {
276 lines.pop();
277 }
278 Ok(lines)
279 }
280
281 pub fn topic_page(&self, syntax: &Syntax, name: &str) -> Outcome<Vec<String>> {
282 let topic = match syntax.get_topic(name) {
283 Some(topic) => topic,
284 None => return Err(err!(
285 "There is no help topic '{}' in '{}'.", name, syntax.config().name;
286 Input, Missing)),
287 };
288 let mut lines = Vec::new();
289 let title = fmt!("{} help {}", syntax.config().name, topic.name);
290 let about = if topic.help.is_empty() { None } else { Some(topic.help.as_str()) };
291 lines.append(&mut self.title_lines(&title, about));
292 lines.push(String::new());
293 lines.append(&mut self.prose(&topic.text));
294 Ok(lines)
295 }
296
297 /// The table, then every command page, then every topic, for a reader who wants it all.
298 pub fn all(&self, syntax: &Syntax) -> Vec<String> {
299 let mut lines = self.summary(syntax);
300 for cmd in syntax.cmds_in_order() {
301 if let Ok(mut page) = self.command_page(syntax, &cmd.config().name) {
302 lines.push(String::new());
303 lines.append(&mut page);
304 }
305 }
306 for topic in &syntax.config().topics {
307 if let Ok(mut page) = self.topic_page(syntax, &topic.name) {
308 lines.push(String::new());
309 lines.append(&mut page);
310 }
311 }
312 lines
313 }
314
315 /// One page holding everything, each command followed by its values and options, as a REPL
316 /// shows it.
317 pub fn to_lines(
318 &self,
319 syntax: &Syntax,
320 )
321 -> Outcome<Vec<String>>
322 {
323 let cfg = syntax.config();
324 let mut lines = Vec::new();
325 if let Some(about) = &cfg.about {
326 lines.append(&mut self.title_lines("", Some(about)));
327 }
328 lines.push(String::new());
329 lines.push(fmt!("USAGE:"));
330 lines.append(&mut self.synopsis("", &self.generic_synopsis(syntax)));
331 let sub = self.cfg.indent + 4;
332 let margs = syntax.args_in_order();
333 if !cfg.vals.is_empty() || !margs.is_empty() {
334 lines.push(fmt!("MSG:"));
335 for val in &cfg.vals {
336 lines.append(&mut self.val_row(val, "", self.cfg.indent));
337 }
338 for arg in margs {
339 lines.append(&mut self.arg_row(arg, self.cfg.indent));
340 }
341 }
342 for (cat, cmds) in Self::categories(syntax) {
343 lines.push(Self::heading(&cat));
344 for cmd in cmds {
345 lines.append(&mut self.row(
346 self.cfg.indent,
347 &[(self.cfg.cmd_effect.as_str(), cmd.config().name.clone())],
348 self.cfg.table_col,
349 &cmd.config().help.as_deref().map(Self::normalise).unwrap_or_default(),
350 ));
351 for val in &cmd.config().vals {
352 lines.append(&mut self.val_row(val, "", sub));
353 }
354 if let Some(rest) = &cmd.config().rest {
355 lines.append(&mut self.val_row(rest, "-- ", sub));
356 }
357 for arg in cmd.args_in_order() {
358 lines.append(&mut self.arg_row(arg, sub));
359 }
360 }
361 }
362 Ok(lines)
363 }
364
365 // ┌───────────────────────┐
366 // │ PIECES │
367 // └───────────────────────┘
368
369 /// Commands grouped by category, categories in the order a command first names them.
370 pub fn categories(syntax: &Syntax) -> Vec<(String, Vec<&Cmd>)> {
371 let mut out: Vec<(String, Vec<&Cmd>)> = Vec::new();
372 for cmd in syntax.cmds_in_order() {
373 let cat = &cmd.config().cat;
374 match out.iter_mut().find(|(c, _)| c == cat) {
375 Some((_, cmds)) => cmds.push(cmd),
376 None => out.push((cat.clone(), vec![cmd])),
377 }
378 }
379 out
380 }
381
382 fn heading(cat: &str) -> String {
383 if cat.is_empty() {
384 fmt!("COMMANDS:")
385 } else {
386 fmt!("{}:", cat.to_uppercase())
387 }
388 }
389
390 /// A command's synopsis after its name: values, options, then what may follow `--`.
391 pub fn cmd_synopsis(cmd: &Cmd) -> Vec<String> {
392 let mut words = Vec::new();
393 for val in &cmd.config().vals {
394 words.push(val.synopsis());
395 }
396 for arg in cmd.args_in_order() {
397 words.push(Self::arg_synopsis(arg));
398 }
399 // The "--" itself is never required; the rest's arity says what must follow it.
400 if let Some(rest) = &cmd.config().rest {
401 words.push(fmt!("[-- {}]", rest.synopsis()));
402 }
403 words
404 }
405
406 pub fn arg_synopsis(arg: &Arg) -> String {
407 let mut s = arg.long_name();
408 for val in &arg.config().vals {
409 s.push(' ');
410 s.push_str(&val.synopsis());
411 }
412 if arg.config().reqd {
413 s
414 } else {
415 fmt!("[{}]", s)
416 }
417 }
418
419 fn generic_synopsis(&self, syntax: &Syntax) -> Vec<String> {
420 let mut words = Vec::new();
421 for val in &syntax.config().vals {
422 words.push(val.synopsis());
423 }
424 if !syntax.args.is_empty() {
425 words.push(fmt!("[<options>]"));
426 }
427 if !syntax.cmds.is_empty() {
428 words.push(fmt!("<command>"));
429 words.push(fmt!("[<values>]"));
430 words.push(fmt!("[<options>]"));
431 if !syntax.config().one_cmd {
432 words.push(fmt!(".."));
433 }
434 }
435 words
436 }
437
438 /// Lays out a synopsis, wrapping so that continuation lines start under the first word
439 /// after the lead.
440 fn synopsis(&self, lead: &str, words: &[String]) -> Vec<String> {
441 let mut lines = Vec::new();
442 let start = if lead.is_empty() {
443 self.cfg.indent
444 } else {
445 self.cfg.indent + lead.chars().count() + 1
446 };
447 let mut line = if lead.is_empty() {
448 self.ind()
449 } else {
450 fmt!("{}{}", self.ind(), lead)
451 };
452 let mut len = line.chars().count();
453 let mut first_on_line = lead.is_empty();
454 for word in words {
455 let wlen = word.chars().count();
456 let sep = if first_on_line { 0 } else { 1 };
457 if !first_on_line && len + sep + wlen > self.cfg.width && len > start {
458 lines.push(line);
459 line = " ".repeat(start);
460 len = start;
461 first_on_line = true;
462 }
463 if !first_on_line {
464 line.push(' ');
465 len += 1;
466 }
467 line.push_str(word);
468 len += wlen;
469 first_on_line = false;
470 }
471 lines.push(line);
472 lines
473 }
474
475 fn val_row(&self, val: &Val, prefix: &str, indent: usize) -> Vec<String> {
476 let label = match val.arity {
477 crate::val::Arity::Many | crate::val::Arity::OneOrMore =>
478 fmt!("{}<{}>...", prefix, val.label()),
479 _ => fmt!("{}<{}>", prefix, val.label()),
480 };
481 self.row(
482 indent,
483 &[(self.cfg.val_effect.as_str(), label)],
484 indent + (self.cfg.desc_col - self.cfg.indent),
485 &Self::normalise_opt(&val.help),
486 )
487 }
488
489 fn arg_row(&self, arg: &Arg, indent: usize) -> Vec<String> {
490 let mut segs: Vec<(&str, String)> = Vec::new();
491 let names = arg.hyphenated_names();
492 let names = if names.is_empty() { vec![arg.canonical_name()] } else { names };
493 segs.push((self.cfg.arg_effect.as_str(), names.join(", ")));
494 for val in &arg.config().vals {
495 segs.push(("", fmt!(" ")));
496 segs.push((self.cfg.val_effect.as_str(), val.synopsis()));
497 }
498 let help = arg.config().help.as_deref().map(Self::normalise).unwrap_or_default();
499 let mut lines = self.row(
500 indent,
501 &segs,
502 indent + (self.cfg.desc_col - self.cfg.indent),
503 &help,
504 );
505 // An option with several values describes each on a line of its own.
506 if arg.config().vals.len() > 1 {
507 for val in &arg.config().vals {
508 lines.append(&mut self.val_row(val, "", indent + 4));
509 }
510 }
511 lines
512 }
513
514 /// A label, then its description from `col`, wrapped with a hanging indent. A label too
515 /// long to leave two spaces before `col` puts the description on the lines below.
516 fn row(
517 &self,
518 indent: usize,
519 label: &[(&str, String)],
520 col: usize,
521 desc: &str,
522 )
523 -> Vec<String>
524 {
525 let plain_len: usize = label.iter().map(|(_, t)| t.chars().count()).sum();
526 let painted: String = label.iter().map(|(e, t)| self.paint(e, t)).collect();
527 let head = fmt!("{}{}", " ".repeat(indent), painted);
528 if desc.is_empty() {
529 return vec![head];
530 }
531 let desc_lines = Self::wrap(desc, self.text_width(col));
532 let mut lines = Vec::new();
533 let mut rest = desc_lines.iter();
534 if indent + plain_len + 2 <= col {
535 match rest.next() {
536 Some(first) => lines.push(fmt!(
537 "{}{}{}", head, " ".repeat(col - indent - plain_len), first)),
538 None => lines.push(head),
539 }
540 } else {
541 lines.push(head);
542 }
543 for line in rest {
544 lines.push(fmt!("{}{}", " ".repeat(col), line));
545 }
546 lines
547 }
548
549 /// Paragraphs wrapped to the width. A paragraph whose every line is indented is kept as it
550 /// stands, for examples and recipes.
551 fn prose(&self, text: &str) -> Vec<String> {
552 let mut lines = Vec::new();
553 for (i, para) in Self::paragraphs(text).iter().enumerate() {
554 if i > 0 {
555 lines.push(String::new());
556 }
557 let verbatim = para.iter().all(|l| l.starts_with(' ') || l.starts_with('\t'));
558 if verbatim {
559 for l in para {
560 lines.push(l.trim_end().to_string());
561 }
562 } else {
563 lines.append(&mut Self::wrap(&para.join(" "), self.cfg.width));
564 }
565 }
566 lines
567 }
568
569 fn paragraphs(text: &str) -> Vec<Vec<&str>> {
570 let mut paras = Vec::new();
571 let mut cur: Vec<&str> = Vec::new();
572 for line in text.lines() {
573 if line.trim().is_empty() {
574 if !cur.is_empty() {
575 paras.push(std::mem::take(&mut cur));
576 }
577 } else {
578 cur.push(line);
579 }
580 }
581 if !cur.is_empty() {
582 paras.push(cur);
583 }
584 paras
585 }
586
587 /// Greedy word wrap. A word longer than the width has a line to itself.
588 pub fn wrap(text: &str, width: usize) -> Vec<String> {
589 let words = text.split_whitespace().map(|w| w.to_string()).collect::<Vec<_>>();
590 Self::wrap_words(&words, width, " ")
591 }
592
593 fn wrap_words(words: &[String], width: usize, sep: &str) -> Vec<String> {
594 let mut lines = Vec::new();
595 let mut line = String::new();
596 let mut len = 0;
597 let slen = sep.chars().count();
598 for word in words {
599 let wlen = word.chars().count();
600 if len > 0 && len + slen + wlen > width {
601 lines.push(std::mem::take(&mut line));
602 len = 0;
603 }
604 if len > 0 {
605 line.push_str(sep);
606 len += slen;
607 }
608 line.push_str(word);
609 len += wlen;
610 }
611 if len > 0 {
612 lines.push(line);
613 }
614 lines
615 }
616
617 /// A page's first line, ' name -- about', the about wrapped under itself.
618 fn title_lines(&self, name: &str, about: Option<&str>) -> Vec<String> {
619 let about = match about {
620 Some(about) => about,
621 None => return vec![fmt!(" {}", name)],
622 };
623 let lead = if name.is_empty() { fmt!(" ") } else { fmt!(" {} -- ", name) };
624 let n = lead.chars().count();
625 let mut lines = Vec::new();
626 for (i, line) in Self::wrap(about, self.text_width(n)).iter().enumerate() {
627 let pad = if i == 0 { lead.clone() } else { " ".repeat(n) };
628 lines.push(fmt!("{}{}", pad, self.paint(&self.cfg.about_effect, line)));
629 }
630 lines
631 }
632
633 fn text_width(&self, col: usize) -> usize {
634 std::cmp::max(20, self.cfg.width.saturating_sub(col))
635 }
636
637 fn ind(&self) -> String { " ".repeat(self.cfg.indent) }
638
639 fn paint(&self, effect: &str, s: &str) -> String {
640 if self.cfg.colour && !effect.is_empty() {
641 fmt!("{}{}{}", effect, s, Term::RESET)
642 } else {
643 s.to_string()
644 }
645 }
646
647 /// A one-line help text as a title's second half: no closing full stop, and a capital
648 /// lowered unless it starts an acronym.
649 fn lowered(s: &str) -> String {
650 let s = s.trim_end_matches('.');
651 let mut chars = s.chars();
652 match (chars.next(), chars.next()) {
653 (Some(a), Some(b)) if a.is_uppercase() && b.is_lowercase() =>
654 a.to_lowercase().chain(s.chars().skip(1)).collect(),
655 _ => s.to_string(),
656 }
657 }
658
659 fn normalise_opt(s: &str) -> String {
660 if s.is_empty() { String::new() } else { Self::normalise(s) }
661 }
662
663 pub fn normalise(s: &str) -> String {
664 if s.ends_with('.') {
665 s.to_string()
666 } else {
667 fmt!("{}.", s)
668 }
669 }
670}