1mod plain;
14pub mod roff;
15
16use clap::CommandFactory;
17use roff::{Headings, Markdown, arg, bold, example_block, inline, italic, line, literal, text};
18
19use crate::settings::{self, DefaultValue, EnvGroup};
20use crate::{exit, keys};
21
22pub type Read<'a> = &'a dyn Fn(&str) -> String;
25
26pub struct Page {
28 pub name: &'static str,
30 pub section: u8,
31 pub text: &'static str,
33 render: fn(&Page, Read) -> String,
34}
35
36macro_rules! page {
37 ($name:literal, $section:literal, $render:expr) => {
38 Page {
39 name: $name,
40 section: $section,
41 text: include_str!(concat!("../../man/", $name, ".", $section)),
42 render: $render,
43 }
44 };
45}
46
47pub const PAGES: &[Page] = &[
49 page!("datui", 1, |p, read| render_datui(p, read)),
50 page!("datui-config", 1, |p, read| render_command(
51 p, read, "config"
52 )),
53 page!("datui-catalog", 1, |p, read| render_command(
54 p, read, "catalog"
55 )),
56 page!("datui-theme", 1, |p, read| render_command(p, read, "theme")),
57 page!("datui-cache", 1, |p, read| render_command(p, read, "cache")),
58 page!("datui-views", 1, |p, read| render_command(p, read, "views")),
59 page!("datui-formats", 1, |p, read| render_command(
60 p, read, "formats"
61 )),
62 page!("datui-completions", 1, |p, read| render_command(
63 p,
64 read,
65 "completions"
66 )),
67 page!("datui-man", 1, |p, read| render_command(p, read, "man")),
68 page!("datui-config", 5, |p, read| render_config_file(p, read)),
69 page!("datui-keys", 7, |p, read| render_keys(p, read)),
70 page!("datui-query", 7, |p, read| render_query(p, read)),
71 page!("datui-formats", 7, |p, read| render_formats(p, read)),
72];
73
74pub const RELEASE_DATE: &str = include_str!("../../release-date.txt");
76
77const BUGS: &str = "https://github.com/derekwisong/datui/issues";
78const DOCS: &str = "https://derekwisong.github.io/datui/";
79
80impl Page {
81 pub fn file_name(&self) -> String {
83 format!("{}.{}", self.name, self.section)
84 }
85
86 pub fn title(&self) -> String {
88 format!("{}({})", self.name, self.section)
89 }
90
91 pub fn path(&self) -> String {
93 format!("crates/datui-cli/man/{}", self.file_name())
94 }
95
96 pub fn render(&self, read: Read) -> String {
98 roff::tidy(&(self.render)(self, read))
99 }
100
101 pub fn roff(&self) -> String {
103 self.text.replace("\r\n", "\n")
104 }
105
106 pub fn plain(&self, width: usize) -> String {
108 plain::render(&self.roff(), width)
109 }
110
111 pub fn summary(&self) -> String {
113 match (self.name, self.section) {
114 ("datui", 1) => lower_first(
115 &crate::Args::command()
116 .get_about()
117 .map(|a| a.to_string())
118 .unwrap_or_default(),
119 ),
120 ("datui-config", 5) => "the datui configuration file".into(),
121 ("datui-keys", 7) => "the keys of every datui screen".into(),
122 ("datui-query", 7) => "the q syntax of the datui command line".into(),
123 ("datui-formats", 7) => "the formats datui reads, and format specs".into(),
124 (name, _) => {
125 let command = name.trim_start_matches("datui-");
126 let about = subcommand(command)
127 .get_about()
128 .map(|a| a.to_string())
129 .unwrap_or_default();
130 let first = about.split([':', ';']).next().unwrap_or(&about);
132 lower_first(first.trim())
133 }
134 }
135 }
136}
137
138pub fn find(query: &str) -> Option<&'static Page> {
141 let query = query.trim();
142 let (name, section) = match query.rsplit_once('.') {
143 Some((name, s)) if s.parse::<u8>().is_ok() => (name, s.parse::<u8>().ok()),
144 _ => (query, None),
145 };
146 let name = name.strip_prefix("datui-").unwrap_or(name);
147 PAGES.iter().find(|p| {
148 let short = p.name.strip_prefix("datui-").unwrap_or(p.name);
149 (short == name || p.name == name) && section.is_none_or(|s| s == p.section)
150 })
151}
152
153fn lower_first(s: &str) -> String {
154 let mut chars = s.chars();
155 match chars.next() {
156 Some(c) if chars.clone().next().is_some_and(|n| n.is_lowercase()) => {
158 c.to_lowercase().chain(chars).collect()
159 }
160 Some(c) => std::iter::once(c).chain(chars).collect(),
161 None => String::new(),
162 }
163}
164
165fn subcommand(name: &str) -> clap::Command {
166 let mut cmd = crate::Args::command();
167 cmd.build();
168 cmd.find_subcommand(name)
169 .unwrap_or_else(|| panic!("no subcommand {name}"))
170 .clone()
171}
172
173fn manual(section: u8) -> &'static str {
175 match section {
176 1 => "User Commands",
177 5 => "File Formats",
178 _ => "Miscellaneous",
179 }
180}
181
182fn head(page: &Page) -> String {
184 let mut out = String::from(".\\\" Generated by gen_docs from crates/datui-cli. Do not edit.\n");
185 out.push_str(&format!(
186 ".TH {} {} {} {} {}\n",
187 literal(&page.name.to_uppercase()),
188 page.section,
189 RELEASE_DATE.trim(),
190 arg(&format!("datui {}", env!("CARGO_PKG_VERSION"))),
191 arg(manual(page.section)),
192 ));
193 out.push_str(".nr HY 0\n.ds AD l\n");
197 out.push_str(&format!(
198 ".SH NAME\n{} \\- {}\n",
199 literal(page.name),
200 text(&page.summary())
201 ));
202 out
203}
204
205fn para(out: &mut String, md: &str) {
207 out.push_str(".PP\n");
208 out.push_str(&line(inline(md)));
209 out.push('\n');
210}
211
212fn item(out: &mut String, tag: &str, md: &str) {
214 out.push_str(".TP\n");
215 out.push_str(&line(tag.to_string()));
216 out.push('\n');
217 out.push_str(&line(inline(md)));
218 out.push('\n');
219}
220
221fn option_tag(a: &clap::Arg) -> String {
223 let values: Vec<String> = a
224 .get_value_names()
225 .map(|names| names.iter().map(|n| n.to_string()).collect())
226 .unwrap_or_default();
227 if a.is_positional() {
228 let names = values
229 .iter()
230 .map(|n| italic(n))
231 .collect::<Vec<_>>()
232 .join(" ");
233 return if a.get_num_args().is_some_and(|n| n.max_values() > 1) {
234 format!("{names} ...")
235 } else {
236 names
237 };
238 }
239 let mut names = Vec::new();
240 if let Some(s) = a.get_short() {
241 names.push(bold(&format!("-{s}")));
242 }
243 if let Some(l) = a.get_long() {
244 names.push(bold(&format!("--{l}")));
245 }
246 let mut tag = names.join(", ");
247 if a.get_action().takes_values() && !values.is_empty() {
248 let value = values
249 .iter()
250 .map(|n| italic(n))
251 .collect::<Vec<_>>()
252 .join(" ");
253 if a.get_num_args().is_some_and(|n| n.min_values() == 0) {
254 tag.push_str(&format!("[={value}]"));
255 } else {
256 tag.push(' ');
257 tag.push_str(&value);
258 }
259 }
260 tag
261}
262
263fn help_of(a: &clap::Arg) -> String {
264 let mut help = a
265 .get_long_help()
266 .or(a.get_help())
267 .map(|h| h.to_string())
268 .unwrap_or_default();
269 let shown: Vec<String> = a
270 .get_possible_values()
271 .iter()
272 .filter(|v| !v.is_hide_set())
273 .map(|v| format!("`{}`", v.get_name()))
274 .collect();
275 if !shown.is_empty() && !a.is_hide_possible_values_set() && a.get_action().takes_values() {
276 help.push_str(&format!(". One of {}", shown.join(", ")));
277 }
278 if !help.is_empty() && !help.ends_with('.') {
279 help.push('.');
280 }
281 help
282}
283
284fn options(out: &mut String, args: &[&clap::Arg]) {
285 for a in args {
286 item(out, &option_tag(a), &help_of(a));
287 }
288}
289
290fn shown_args(cmd: &clap::Command) -> Vec<&clap::Arg> {
291 cmd.get_arguments()
292 .filter(|a| !a.is_hide_set())
293 .filter(|a| !matches!(a.get_id().as_str(), "help" | "version"))
294 .collect()
295}
296
297fn exit_status(out: &mut String, session: bool) {
298 out.push_str(".SH \"EXIT STATUS\"\n");
299 for s in exit::STATUSES.iter().filter(|s| session || !s.session_only) {
300 item(out, &bold(&s.code.to_string()), s.means);
301 }
302}
303
304fn environment(out: &mut String, names: &[&str]) {
305 out.push_str(".SH ENVIRONMENT\n");
306 for var in settings::ENVIRONMENT
307 .iter()
308 .filter(|v| names.is_empty() || v.names.iter().any(|n| names.contains(n)))
309 {
310 environment_entry(out, var);
311 }
312}
313
314fn environment_entry(out: &mut String, var: &settings::EnvVar) {
315 let tag = var
316 .names
317 .iter()
318 .map(|n| bold(n))
319 .collect::<Vec<_>>()
320 .join(", ");
321 item(out, &tag, &format!("{}.", var.doc));
322}
323
324fn files(out: &mut String, which: Files) {
326 out.push_str(".SH FILES\n");
327 para(
328 out,
329 "*CONFIG* is `$DATUI_CONFIG_DIR` when it is set, else `$XDG_CONFIG_HOME/datui` (`~/.config/datui`) on Linux, `~/Library/Application Support/datui` on macOS and `%APPDATA%\\datui` on Windows.",
330 );
331 if which.cache {
332 para(
333 out,
334 "*CACHE* is `$DATUI_CACHE_DIR` when it is set, else `$XDG_CACHE_HOME/datui` (`~/.cache/datui`) on Linux, `~/Library/Caches/datui` on macOS and `%LOCALAPPDATA%\\datui` on Windows.",
335 );
336 }
337 let config: &[(&str, &str)] = &[
338 (
339 "CONFIG/config.toml",
340 "The config file: see datui-config(5). `datui config path` prints the files read",
341 ),
342 (
343 "CONFIG/catalog.toml",
344 "Your catalog, which Ctrl+D on the home screen adds to: see datui-catalog(1)",
345 ),
346 (
347 "CONFIG/catalogs/",
348 "More catalogs, one *.toml each, named by its file; examples.toml replaces the bundled one",
349 ),
350 ("CONFIG/views/", "Saved views: see datui-views(1)"),
351 (
352 "CONFIG/formats/",
353 "Format specs and dictionaries, searched first: see datui-formats(7)",
354 ),
355 ];
356 let cache: &[(&str, &str)] = &[
357 (
358 "CACHE/recents_history.txt",
359 "The recent datasets the home screen lists. `datui cache clear --recents` forgets them",
360 ),
361 ("CACHE/*_history.txt", "The prompts' history"),
362 (
363 "CACHE/shapes/, CACHE/facts/, CACHE/cloud_listings/",
364 "What the home screen has measured of datasets and listed of cloud sources, so it can show rows, columns and sizes without reading them again",
365 ),
366 (
367 "CACHE/datui.log",
368 "The log, unless `log.file` names another",
369 ),
370 ];
371 let mut entries: Vec<&(&str, &str)> = Vec::new();
372 if which.config {
373 entries.extend(config);
374 }
375 if which.cache {
376 entries.extend(cache);
377 }
378 for (path, what) in entries {
379 let tag = path
380 .split(", ")
381 .map(|p| {
382 let (dir, rest) = p.split_once('/').unwrap_or((p, ""));
383 format!("\\fI{}\\fR/{}", literal(dir), literal(rest))
384 })
385 .collect::<Vec<_>>()
386 .join(", ");
387 item(out, &tag, &format!("{what}."));
388 }
389}
390
391#[derive(Clone, Copy)]
392struct Files {
393 config: bool,
394 cache: bool,
395}
396
397fn examples(out: &mut String, page: &Page) {
398 let examples = crate::examples_of(&page.file_name());
399 if examples.is_empty() {
400 return;
401 }
402 out.push_str(".SH EXAMPLES\n");
403 for example in examples {
404 para(out, &format!("{}.", example.description));
405 for file in &example.files {
407 out.push_str(&format!(".PP\n{}\n", line(bold(&file.name))));
408 example_block(out, &file.text);
409 }
410 example_block(out, &example.command);
411 }
412}
413
414fn see_also(out: &mut String, page: &Page, others: &[&str]) {
415 out.push_str(".SH \"SEE ALSO\"\n");
416 let mut refs: Vec<String> = PAGES
417 .iter()
418 .filter(|p| p.file_name() != page.file_name())
419 .filter(|p| {
420 page.name == "datui" || p.name == "datui" || others.contains(&p.file_name().as_str())
421 })
422 .map(|p| format!("{}({})", bold(p.name), p.section))
423 .collect();
424 let external: &[(&str, u8)] = match page.name {
425 "datui" => &[("jq", 1), ("less", 1), ("journalctl", 1), ("vd", 1)],
426 "datui-man" => &[("man", 1)],
427 _ => &[],
428 };
429 refs.extend(external.iter().map(|(n, s)| format!("{}({s})", bold(n))));
430 out.push_str(&line(refs.join(", ")));
431 out.push('\n');
432 para(out, &format!("The datui documentation: <{DOCS}>"));
433}
434
435fn tail(out: &mut String, read: Read) {
437 out.push_str(".SH BUGS\n");
438 para(out, &format!("Report bugs at <{BUGS}>."));
439 out.push_str(".SH AUTHORS\n");
440 para(out, "Derek Wisong and the datui contributors.");
441 let license = read("LICENSE");
442 let copyright = license
443 .lines()
444 .find(|l| l.starts_with("Copyright"))
445 .unwrap_or("Copyright (c) Derek Wisong");
446 out.push_str(".SH COPYRIGHT\n.PP\n");
447 out.push_str(&line(text(copyright).replace("(c)", "\\(co")));
448 out.push('\n');
449 para(out, "datui is free software under the MIT License.");
450}
451
452fn render_datui(page: &Page, read: Read) -> String {
453 let mut cmd = crate::Args::command();
454 cmd.build();
455 let mut out = head(page);
456
457 out.push_str(".SH SYNOPSIS\n.nf\n");
458 out.push_str(&format!(
459 "{} [{}]... [{}]...\n",
460 bold("datui"),
461 italic("OPTION"),
462 italic("PATH")
463 ));
464 out.push_str(&format!(
465 "{} | {} [{}]... [{}]\n",
466 italic("command"),
467 bold("datui"),
468 italic("OPTION"),
469 bold("-")
470 ));
471 out.push_str(&format!(
472 "{} {} [{}]...\n",
473 bold("datui"),
474 italic("COMMAND"),
475 italic("ARG")
476 ));
477 out.push_str(".fi\n");
478
479 out.push_str(".SH DESCRIPTION\n");
480 for paragraph in read("crates/datui-cli/long_about.txt").split("\n\n") {
481 para(
482 &mut out,
483 ¶graph.split_whitespace().collect::<Vec<_>>().join(" "),
484 );
485 }
486 para(
487 &mut out,
488 "Each *PATH* is a file, a directory, a glob, or an `http://`, `https://`, `s3://`, `gs://` or `az://` (`abfss://`) URL. Files of one shape are read as one table. `-` reads standard input, as does no *PATH* when data is piped in; with no *PATH* and nothing piped in, datui starts at its home screen.",
489 );
490 para(
491 &mut out,
492 "A file is scanned where it is wherever its format allows, and only the rows on screen are read; sorting, queries and analysis read what they need. datui-formats(7) says how each format is read. On any screen, `?` shows its keys; datui-keys(7) lists them all.",
493 );
494
495 out.push_str(".SH OPTIONS\n");
496 let mut groups: Vec<Option<String>> = vec![None];
497 for a in cmd.get_arguments() {
498 let heading = a.get_help_heading().map(str::to_string);
499 if !groups.contains(&heading) {
500 groups.push(heading);
501 }
502 }
503 for group in groups {
504 let args: Vec<&clap::Arg> = shown_args(&cmd)
505 .into_iter()
506 .filter(|a| a.get_help_heading().map(str::to_string) == group)
507 .collect();
508 if args.is_empty() {
509 continue;
510 }
511 out.push_str(&format!(
512 ".SS {}\n",
513 arg(&text(group.as_deref().unwrap_or("Arguments")))
514 ));
515 options(&mut out, &args);
516 }
517 out.push_str(".SS Help\n");
518 item(
519 &mut out,
520 &format!("{}, {}", bold("-h"), bold("--help")),
521 "Print help: a summary with `-h`, more with `--help`.",
522 );
523 item(
524 &mut out,
525 &format!("{}, {}", bold("-V"), bold("--version")),
526 "Print the version.",
527 );
528
529 out.push_str(".SH COMMANDS\n");
530 for sub in cmd.get_subcommands().filter(|c| c.get_name() != "help") {
531 let about = sub.get_about().map(|a| a.to_string()).unwrap_or_default();
532 item(
533 &mut out,
534 &bold(&format!("datui {}", sub.get_name())),
535 &format!("{about}. See datui-{}(1).", sub.get_name()),
536 );
537 }
538
539 exit_status(&mut out, true);
540
541 out.push_str(".SH ENVIRONMENT\n");
542 for group in [
543 EnvGroup::Datui,
544 EnvGroup::Terminal,
545 EnvGroup::Programs,
546 EnvGroup::Cloud,
547 ] {
548 out.push_str(&format!(".SS {}\n", arg(&text(group.title()))));
549 if group == EnvGroup::Cloud {
550 para(
551 &mut out,
552 "Read as each provider's own tools read them; a variable set but empty counts as unset. `[cloud] env_files` can read them from `.env` files.",
553 );
554 }
555 for var in settings::ENVIRONMENT.iter().filter(|v| v.group == group) {
556 environment_entry(&mut out, var);
557 }
558 }
559
560 files(
561 &mut out,
562 Files {
563 config: true,
564 cache: true,
565 },
566 );
567 examples(&mut out, page);
568 see_also(&mut out, page, &[]);
569 tail(&mut out, read);
570 out
571}
572
573fn render_command(page: &Page, read: Read, name: &str) -> String {
575 let cmd = subcommand(name);
576 let mut out = head(page);
577 let actions: Vec<&clap::Command> = cmd
578 .get_subcommands()
579 .filter(|c| c.get_name() != "help")
580 .collect();
581 let synopsis = |words: &str, c: &clap::Command| -> String {
582 let mut s = bold(words);
583 for a in shown_args(c) {
584 if a.get_id() == "config" {
585 continue;
586 }
587 let tag = option_tag(a);
588 if a.is_positional() && a.is_required_set() {
589 s.push_str(&format!(" {tag}"));
590 } else {
591 s.push_str(&format!(" [{tag}]"));
592 }
593 }
594 s
595 };
596
597 out.push_str(".SH SYNOPSIS\n.nf\n");
598 if actions.is_empty() || !cmd.is_subcommand_required_set() {
599 out.push_str(&synopsis(&format!("datui {name}"), &cmd));
600 out.push('\n');
601 }
602 for action in &actions {
603 out.push_str(&synopsis(
604 &format!("datui {name} {}", action.get_name()),
605 action,
606 ));
607 out.push('\n');
608 }
609 out.push_str(".fi\n");
610
611 out.push_str(".SH DESCRIPTION\n");
612 let about = cmd
613 .get_long_about()
614 .or(cmd.get_about())
615 .map(|a| a.to_string())
616 .unwrap_or_default();
617 para(&mut out, &format!("{about}."));
618 let extra = match name {
619 "config" => "The file's keys are in datui-config(5).",
620 "catalog" => {
621 "A catalog is one TOML file of named datasets, local or remote, that the home screen lists as a section under its label. *CONFIG*/catalog.toml is yours, and Ctrl+D on a home row adds to it; every *CONFIG*/catalogs/*.toml is a catalog, named by its file, and `catalogs` in the config lists files elsewhere; examples ships with datui, and a catalogs/examples.toml replaces it. A catalog's top level holds `label` and `description`; every other table is a dataset, keyed by a short id of lowercase letters, digits and `-`. A dataset's keys: `name` (its row), `path` or `url`, `auth` (`auto` or `anonymous`) or `connection` (a `[[cloud.connections]]` name), `description`, `publisher`, `license`, `homepage`, `documentation` (an https link), `size` (a web file's bytes, shown until measured), `columns.NAME = { description, unit, values = { CODE = \"meaning\" } }` and `bookmarks.\"Name\" = \"path/\"`. A long legend is a `[id.columns.NAME.values]` table, with the column's other keys written as dotted keys. `datui catalog show examples` prints a worked example."
622 }
623 "theme" => {
624 "A theme is a set of colors, one per slot. night-market (dark) and day-market (light) are built in; every *CONFIG*/themes/*.toml is a theme, named by its file. A theme file holds `theme.colors` slots, plus `extends` (the theme its unset slots come from; without it, the built-in for the mode it is used in) and `description`. `theme.dark` and `theme.light` in the config pick the theme for each mode, and `theme.colors` lies over whichever is in use. A file with a mistake is left out with a warning, and its mode uses the built-in."
625 }
626 "cache" => {
627 "The cache holds nothing datui cannot rebuild; clearing it loses the recents' order and the prompts' history."
628 }
629 "views" => {
630 "A view is saved from the views list (`v`) at the table, and applied with `--view NAME` or from that list."
631 }
632 "formats" => {
633 "Format specs are TOML files that describe a binary format, or a family of delimited text files; datui-formats(7) describes them. The search path is *CONFIG*/formats, then `$DATUI_FORMATS_PATH`, then `[formats] path` in the config."
634 }
635 "completions" => {
636 "The script completes datui's options, commands and their values. Print it into the directory your shell loads completions from."
637 }
638 "man" => {
639 "With no option, prints the page, or shows it with man(1) when standard output is a terminal; where man(1) is missing, as plain text through a pager: `$PAGER`, else less(1) or more(1). `--dir` writes every page, so `man datui` finds them; a package or the release archive installs them already. *PAGE* is a page's name with or without `datui-`, and `.5` or `.7` for the file and topic pages when a command shares the name."
640 }
641 _ => "",
642 };
643 if !extra.is_empty() {
644 para(&mut out, extra);
645 }
646
647 if !actions.is_empty() {
648 out.push_str(".SH COMMANDS\n");
649 for action in &actions {
650 let about = action
651 .get_about()
652 .map(|a| a.to_string())
653 .unwrap_or_default();
654 item(
655 &mut out,
656 &bold(&format!("datui {name} {}", action.get_name())),
657 &format!("{about}."),
658 );
659 let args: Vec<&clap::Arg> = shown_args(action)
660 .into_iter()
661 .filter(|a| a.get_id() != "config")
662 .collect();
663 if !args.is_empty() {
664 out.push_str(".RS\n");
665 options(&mut out, &args);
666 out.push_str(".RE\n");
667 }
668 }
669 }
670
671 out.push_str(".SH OPTIONS\n");
672 let own: Vec<&clap::Arg> = shown_args(&cmd)
673 .into_iter()
674 .filter(|a| a.get_id() != "config")
675 .collect();
676 options(&mut out, &own);
677 let global = crate::Args::command();
678 if let Some(config) = global.get_arguments().find(|a| a.get_id() == "config") {
679 options(&mut out, &[config]);
680 }
681 item(
682 &mut out,
683 &format!("{}, {}", bold("-h"), bold("--help")),
684 "Print help.",
685 );
686
687 exit_status(&mut out, false);
688 let (env, which, related): (&[&str], Option<Files>, &[&str]) = match name {
689 "config" => (
690 &["DATUI_CONFIG_DIR", "DATUI_LOG"],
691 Some(Files {
692 config: true,
693 cache: false,
694 }),
695 &["datui-config.5"],
696 ),
697 "catalog" => (
698 &["DATUI_CONFIG_DIR"],
699 Some(Files {
700 config: true,
701 cache: false,
702 }),
703 &["datui-config.5"],
704 ),
705 "theme" => (
706 &["DATUI_CONFIG_DIR"],
707 Some(Files {
708 config: true,
709 cache: false,
710 }),
711 &["datui-config.5"],
712 ),
713 "cache" => (
714 &["DATUI_CACHE_DIR"],
715 Some(Files {
716 config: false,
717 cache: true,
718 }),
719 &[],
720 ),
721 "views" => (
722 &["DATUI_CONFIG_DIR"],
723 Some(Files {
724 config: true,
725 cache: false,
726 }),
727 &[],
728 ),
729 "formats" => (
730 &["DATUI_CONFIG_DIR", "DATUI_FORMATS_PATH"],
731 Some(Files {
732 config: true,
733 cache: false,
734 }),
735 &["datui-formats.7"],
736 ),
737 _ => (&[], None, &[]),
738 };
739 if !env.is_empty() {
740 environment(&mut out, env);
741 }
742 if let Some(which) = which {
743 files(&mut out, which);
744 }
745 examples(&mut out, page);
746 see_also(&mut out, page, related);
747 tail(&mut out, read);
748 out
749}
750
751fn render_config_file(page: &Page, read: Read) -> String {
752 let mut out = head(page);
753 out.push_str(".SH SYNOPSIS\n.nf\n");
754 out.push_str(&format!("\\fICONFIG\\fR/{}\n", literal("config.toml")));
755 out.push_str(&format!(
756 "{} {} {}\n",
757 bold("datui"),
758 bold("-c"),
759 italic("KEY=VALUE")
760 ));
761 out.push_str(".fi\n");
762 out.push_str(".SH DESCRIPTION\n");
763 para(
764 &mut out,
765 "datui's settings are TOML: a table per section, `[display]`, and a key per setting. A key is written `section.name` here and with `-c`.",
766 );
767 para(
768 &mut out,
769 "Values are taken, lowest first, from the defaults, the files listed in `import` (in order), the config file, `-c KEY=VALUE` (repeatable), and a key's own flag. `datui config init` writes the file with every key commented out at its default, `datui config path` prints the files read, and `datui config keys` lists every key with its value in effect and what set it. A key datui does not know, or a value a key does not take, is an error that names it.",
770 );
771 out.push_str(".SS Types\n");
772 for (kind, written) in settings::TYPES {
773 item(&mut out, &italic(kind), written);
774 }
775 item(&mut out, &italic("bool"), "`true` or `false`.");
776 item(&mut out, &italic("path"), "A path; `~` and `$VAR` expand.");
777
778 out.push_str(".SH SETTINGS\n");
779 for section in settings::SECTIONS {
780 let keys: Vec<&settings::Setting> = settings::in_section(section.name).collect();
781 if keys.is_empty() {
782 continue;
783 }
784 let title = if section.name.is_empty() {
785 "Top level".to_string()
786 } else {
787 format!("[{}]", section.name)
788 };
789 out.push_str(&format!(".SS {}\n", arg(&literal(&title))));
790 if !section.intro.is_empty() {
791 para(&mut out, section.intro);
792 }
793 for setting in keys {
794 let kind = setting.kind.describe().replace("\\|", "|");
795 let tag = format!("{} ({})", bold(setting.key), italic(&kind));
796 let mut body = setting.doc.to_string();
797 if !body.ends_with('.') {
798 body.push('.');
799 }
800 match setting.default {
801 DefaultValue::Value(v) => body.push_str(&format!(" Default: `{v}`.")),
802 DefaultValue::Unset(_) => body.push_str(" Unset by default."),
803 DefaultValue::Color { dark, light } => {
804 body.push_str(&format!(" Default: `{dark}` dark, `{light}` light."))
805 }
806 }
807 if let Some(flag) = setting.flag {
808 body.push_str(&format!(" Flag: `--{flag}`."));
809 }
810 item(&mut out, &tag, &body.replace('|', "\\|"));
812 }
813 }
814
815 environment(&mut out, &["DATUI_CONFIG_DIR", "DATUI_LOG"]);
816 files(
817 &mut out,
818 Files {
819 config: true,
820 cache: false,
821 },
822 );
823 examples(&mut out, page);
824 see_also(&mut out, page, &["datui-config.1"]);
825 tail(&mut out, read);
826 out
827}
828
829fn key_group(out: &mut String, name: Option<&str>, keys: &[keys::Key]) {
831 if let Some(name) = name {
832 out.push_str(".PP\n");
833 out.push_str(&line(format!("\\fI{}\\fR", text(name))));
834 out.push('\n');
835 }
836 for key in keys {
837 out.push_str(".TP\n");
838 out.push_str(&line(bold(key.keys)));
839 out.push('\n');
840 out.push_str(&line(text(key.long())));
841 out.push('\n');
842 }
843}
844
845fn render_keys(page: &Page, read: Read) -> String {
846 let mut out = head(page);
847 out.push_str(".SH DESCRIPTION\n");
848 para(
849 &mut out,
850 "`?` or `F1` on any screen shows that screen's keys, grouped by task: `/` narrows them to those whose text matches, and Enter closes the help and presses the key on the line. This page lists every screen's keys, with the longer descriptions.",
851 );
852 out.push_str(".SH KEYS\n");
853 out.push_str(&format!(".SS {}\n", arg(&text("Every screen"))));
854 key_group(&mut out, None, keys::GLOBAL.keys);
855 out.push_str(&format!(".SS {}\n", arg(&text("Help"))));
856 key_group(&mut out, None, keys::HELP.keys);
857 for screen in keys::SCREENS {
858 out.push_str(&format!(".SS {}\n", arg(&text(screen.title))));
859 para(&mut out, screen.reached);
860 for group in screen.groups {
861 key_group(&mut out, Some(group.name), group.keys);
862 }
863 }
864 examples(&mut out, page);
865 see_also(&mut out, page, &[]);
866 tail(&mut out, read);
867 out
868}
869
870fn datasets(read: Read) -> toml::Table {
872 read("scripts/docs/doc_datasets.toml")
873 .parse()
874 .expect("doc_datasets.toml is TOML")
875}
876
877fn render_query(page: &Page, read: Read) -> String {
878 let mut out = head(page);
879 out.push_str(".SH SYNOPSIS\n.nf\n");
880 out.push_str(&format!(
881 "{} [{}] [{} {}] [{} {}]\n",
882 bold("select"),
883 italic("columns"),
884 bold("by"),
885 italic("groups"),
886 bold("where"),
887 italic("conditions")
888 ));
889 out.push_str(".fi\n");
890 out.push_str(".SH DESCRIPTION\n");
891 out.push_str(".SS In brief\n");
893 for (example, meaning) in keys::Q_SUMMARY {
894 out.push_str(".TP\n");
895 out.push_str(&line(literal(example)));
896 out.push('\n');
897 out.push_str(&line(text(meaning)));
898 out.push('\n');
899 }
900 let doc = read("docs/reference/query-syntax.md");
901 let table = datasets(read);
902 let label = |name: &str| -> String { format!("On the {} dataset (see DATASETS):", bold(name)) };
903 let md = Markdown {
904 headings: Headings::Sections,
905 dataset_label: Some(&label),
906 };
907 out.push_str(&md.render(&doc));
908
909 let used: Vec<String> = doc
911 .lines()
912 .filter_map(|l| l.trim().strip_prefix("```"))
913 .filter_map(|info| {
914 info.split(',')
915 .find_map(|a| a.trim().strip_prefix("dataset="))
916 })
917 .map(str::to_string)
918 .fold(Vec::new(), |mut v, n| {
919 if !v.contains(&n) {
920 v.push(n);
921 }
922 v
923 });
924 out.push_str(".SH DATASETS\n");
925 para(
926 &mut out,
927 "The examples run on the Example datasets that come with datui (on the home screen). Open one, press `/`, and type the query.",
928 );
929 for name in used {
930 let entry = table.get(&name).and_then(|e| e.as_table());
931 let open = entry
932 .and_then(|e| e.get("url").or_else(|| e.get("open")))
933 .and_then(|v| v.as_str())
934 .unwrap_or_default();
935 out.push_str(&format!(".TP\n{}\n", line(bold(&name))));
936 out.push_str(".EX\n");
937 out.push_str(&line(literal(&format!("datui {open}"))));
938 out.push_str("\n.EE\n");
939 }
940 examples(&mut out, page);
941 see_also(&mut out, page, &[]);
942 tail(&mut out, read);
943 out
944}
945
946fn render_formats(page: &Page, read: Read) -> String {
947 let mut out = head(page);
948 out.push_str(".SH DESCRIPTION\n");
949 para(&mut out, &crate::docgen::format_count_sentence());
950 let sections = Markdown {
951 headings: Headings::Sections,
952 dataset_label: None,
953 };
954 let inside = Markdown {
955 headings: Headings::Subsections,
956 dataset_label: None,
957 };
958 let index = read("docs/formats/index.md");
960 let index = match crate::docgen::splice("docs/formats/index.md", &index, "format-count", "") {
961 Ok(text) => text,
962 Err(_) => index,
963 };
964 out.push_str(§ions.render(&index));
965 out.push_str(".SH \"FORMAT SPECS\"\n");
966 out.push_str(&inside.render(&read("docs/formats/format-specs.md")));
967 out.push_str(".SH \"FORMAT SPEC REFERENCE\"\n");
968 out.push_str(&inside.render(&read("docs/reference/format-specs.md")));
969 examples(&mut out, page);
970 see_also(&mut out, page, &["datui-formats.1"]);
971 tail(&mut out, read);
972 out
973}
974
975pub fn render_markdown_index() -> String {
977 let mut out = String::from("| Page | What it covers |\n|---|---|\n");
978 for page in PAGES {
979 out.push_str(&format!(
980 "| [{}](man/{}.html) | {} |\n",
981 page.title(),
982 page.file_name(),
983 page.summary()
984 ));
985 }
986 out
987}
988
989#[cfg(test)]
990mod tests;