Skip to main content

datui_cli/man/
mod.rs

1//! The manpages, rendered from the sources the docs render from: the clap
2//! definitions, the option, environment and key registries, the format descriptors,
3//! `examples.toml` and the query and format-spec references.
4//!
5//! The pages are rendered by `gen_docs write` and committed under
6//! `crates/datui-cli/man/`; `the_generated_docs_are_current` fails while a committed
7//! page differs. Committed, they need nothing at build time: the
8//! references live outside this crate, which a build from crates.io could not read,
9//! and every package, tarball and `datui man` takes the same files. The date is the
10//! release's (`release-date.txt`, set by `scripts/bump_version.py`), never the build's,
11//! so a page is the same however and whenever it is built.
12
13mod 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
22/// What a page renders from, beyond this crate: a repository file's text, by its path
23/// from the root.
24pub type Read<'a> = &'a dyn Fn(&str) -> String;
25
26/// One manpage.
27pub struct Page {
28    /// `datui-config`.
29    pub name: &'static str,
30    pub section: u8,
31    /// The committed page, as `datui man` prints it.
32    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
47/// Every page, in the order `datui man --list` and the docs list them.
48pub 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
74/// The date every page carries: the release's, from `release-date.txt`.
75pub 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    /// `datui-config.5`: the file name, and how examples name the page.
82    pub fn file_name(&self) -> String {
83        format!("{}.{}", self.name, self.section)
84    }
85
86    /// `datui-config(5)`.
87    pub fn title(&self) -> String {
88        format!("{}({})", self.name, self.section)
89    }
90
91    /// Where the page is committed, from the repository's root.
92    pub fn path(&self) -> String {
93        format!("crates/datui-cli/man/{}", self.file_name())
94    }
95
96    /// The page as `gen_docs` renders it.
97    pub fn render(&self, read: Read) -> String {
98        roff::tidy(&(self.render)(self, read))
99    }
100
101    /// The committed page's text with a Windows checkout's line endings undone.
102    pub fn roff(&self) -> String {
103        self.text.replace("\r\n", "\n")
104    }
105
106    /// The page as plain text filled to `width` columns, for a system without `man`.
107    pub fn plain(&self, width: usize) -> String {
108        plain::render(&self.roff(), width)
109    }
110
111    /// The NAME line's description, as `whatis` and `datui man --list` show it.
112    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                // The first clause: the NAME line is one line.
131                let first = about.split([':', ';']).next().unwrap_or(&about);
132                lower_first(first.trim())
133            }
134        }
135    }
136}
137
138/// The page `query` names: `datui-config`, `config`, `config.5`, `keys`. A name that
139/// two sections share (`config`) is the command's, as `man` picks section 1 first.
140pub 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        // An initialism (`SQL`) keeps its case.
157        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
173/// The manual a section belongs to, as `.TH` names it.
174fn manual(section: u8) -> &'static str {
175    match section {
176        1 => "User Commands",
177        5 => "File Formats",
178        _ => "Miscellaneous",
179    }
180}
181
182/// `.TH` and NAME.
183fn 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    // No hyphenation and a ragged right, through the registers groff's man macros
194    // reset each paragraph from: an identifier (`DATUI_GCP_PROJECT`, a dotted key)
195    // broken at a hyphen reads as another word.
196    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
205/// A paragraph of Markdown.
206fn 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
212/// A tagged paragraph: the tag is roff already, the body Markdown.
213fn 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
221/// An option's tag: `-F, --format FMT`, `--infer-types[=COLS|off]`, `PATH...`.
222fn 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
324/// Where the config and cache directories are, then the files in them.
325fn 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        // Each file it reads under its name, then the command.
406        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
435/// BUGS, AUTHORS and COPYRIGHT, the year from the license.
436fn 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            &paragraph.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
573/// The subcommands' pages, git-style: `datui-config(1)` for `datui config`.
574fn 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            // A doc's own mention of a flag or key reads as Markdown would show it.
811            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
829/// One group of keys: its name, then a tagged paragraph per key.
830fn 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
870/// What a query example runs on: the datasets of `scripts/docs/doc_datasets.toml`.
871fn 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    // The summary the command line's help shows.
892    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    // Every dataset an example names, and how to open it.
910    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    // The overview's count sentence is above already.
959    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(&sections.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
975/// `docs/reference/manual-pages.md`'s list: each page, linked to its HTML.
976pub 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;