Skip to main content

datui_cli/
docgen.rs

1//! The parts of the docs written from the code, and the one place that writes them.
2//!
3//! Each [`Generated`] is a whole page or a marked region of one. `gen_docs write`
4//! writes them all; `the_generated_docs_are_current` fails while a committed copy
5//! differs. The renderers are plain functions over the registries (options, settings,
6//! environment, formats, keys), so a manpage can render the same sources as roff.
7//!
8//! A region sits between two comments, which mdBook and GitHub hide:
9//!
10//! ```text
11//! <!-- generated: NAME -->
12//! ...
13//! <!-- end generated: NAME -->
14//! ```
15//!
16//! In a Jinja template the comments are `{# generated: NAME #}`, and in TOML, Ruby
17//! and desktop files `# generated: NAME`, so they stay out of what the file renders.
18
19use std::path::{Path, PathBuf};
20
21use crate::formats::{FileFormat, RemoteRead, Stored};
22use crate::settings;
23
24mod site;
25
26/// What renders a generated part. Its argument reads a repository file by its path
27/// from the root, line endings as LF.
28type Render = fn(&dyn Fn(&str) -> String) -> String;
29
30/// A page, or a region of one, written from the code.
31pub struct Generated {
32    /// From the repository's root.
33    pub file: &'static str,
34    /// The region's name, or `None` for the whole file.
35    pub region: Option<&'static str>,
36    pub render: Render,
37}
38
39/// Every generated part of the docs.
40pub const GENERATED: &[Generated] = &[
41    Generated {
42        file: "docs/reference/command-line-options.md",
43        region: None,
44        render: |_| crate::render_options_markdown(),
45    },
46    Generated {
47        file: "docs/reference/settings.md",
48        region: None,
49        render: |_| settings::render_settings_markdown(),
50    },
51    Generated {
52        file: "docs/reference/environment.md",
53        region: None,
54        render: |_| settings::render_environment_markdown(),
55    },
56    Generated {
57        file: "docs/reference/keyboard-shortcuts.md",
58        region: Some("keys"),
59        render: |_| crate::keys::render_markdown(),
60    },
61    Generated {
62        file: "docs/formats/index.md",
63        region: Some("formats"),
64        render: |_| render_formats_markdown(),
65    },
66    Generated {
67        file: "docs/formats/index.md",
68        region: Some("format-count"),
69        render: |_| format_count_sentence(),
70    },
71    Generated {
72        file: "docs/introduction.md",
73        region: Some("pitch"),
74        render: |_| site::PITCH.to_string(),
75    },
76    Generated {
77        file: "README.md",
78        region: Some("pitch"),
79        render: |_| site::PITCH.to_string(),
80    },
81    Generated {
82        file: "docs/reference/python-api.md",
83        region: Some("options"),
84        render: |_| render_python_options_markdown(),
85    },
86    Generated {
87        file: "docs/reference/catalogs.md",
88        region: Some("public-catalog"),
89        render: |read| render_public_catalog(&read("crates/datui-lib/src/public_catalog.toml")),
90    },
91    Generated {
92        file: "docs/reference/manual-pages.md",
93        region: Some("pages"),
94        render: |_| crate::man::render_markdown_index(),
95    },
96    Generated {
97        file: "README.md",
98        region: Some("install"),
99        render: site::readme_install,
100    },
101    Generated {
102        file: "docs/getting-started/installation.md",
103        region: Some("install-script"),
104        render: site::docs_install_script,
105    },
106    Generated {
107        file: "docs/getting-started/installation.md",
108        region: Some("install-table"),
109        render: site::docs_install_table,
110    },
111    Generated {
112        file: "docs/getting-started/installation.md",
113        region: Some("install-apt"),
114        render: |read| site::channel_block(read, "apt"),
115    },
116    Generated {
117        file: "docs/getting-started/installation.md",
118        region: Some("install-dnf"),
119        render: |read| site::channel_block(read, "dnf"),
120    },
121    Generated {
122        file: "scripts/docs/index.html.j2",
123        region: Some("formats"),
124        render: |_| site::landing_formats(),
125    },
126    Generated {
127        file: "scripts/docs/index.html.j2",
128        region: Some("install"),
129        render: site::landing_install,
130    },
131    Generated {
132        file: "Cargo.toml",
133        region: Some("description"),
134        render: |_| format!("description = {}", site::toml_string(&site::summary())),
135    },
136    Generated {
137        file: "Cargo.toml",
138        region: Some("deb-description"),
139        render: |_| {
140            format!(
141                "extended-description = {}",
142                site::toml_string(&site::description())
143            )
144        },
145    },
146    Generated {
147        file: "python/pyproject.toml",
148        region: Some("description"),
149        render: |_| format!("description = {}", site::toml_string(&site::summary())),
150    },
151    Generated {
152        file: "scripts/packaging/homebrew-formula.rb.template",
153        region: Some("desc"),
154        render: |_| format!("  desc {}", site::toml_string(&site::summary())),
155    },
156    Generated {
157        file: "scripts/packaging/datui.desktop",
158        region: Some("comment"),
159        render: |_| format!("Comment={}", site::TAGLINE),
160    },
161];
162
163/// The opening and closing comments of a region in `file`.
164fn markers(file: &str, name: &str) -> (String, String) {
165    if file.ends_with(".j2") {
166        (
167            format!("{{# generated: {name} #}}"),
168            format!("{{# end generated: {name} #}}"),
169        )
170    } else if file.ends_with(".md") {
171        (
172            format!("<!-- generated: {name} -->"),
173            format!("<!-- end generated: {name} -->"),
174        )
175    } else {
176        (
177            format!("# generated: {name}"),
178            format!("# end generated: {name}"),
179        )
180    }
181}
182
183/// `text`, the contents of `file`, with region `name` replaced by `content`. An
184/// error when the region's markers are missing.
185pub fn splice(file: &str, text: &str, name: &str, content: &str) -> Result<String, String> {
186    let (open, close) = markers(file, name);
187    let start = text.find(&open).ok_or_else(|| format!("no `{open}`"))? + open.len();
188    let end = text[start..]
189        .find(&close)
190        .ok_or_else(|| format!("no `{close}`"))?
191        + start;
192    Ok(format!(
193        "{}\n{}\n{}",
194        &text[..start],
195        content.trim_matches('\n'),
196        &text[end..]
197    ))
198}
199
200/// The repository's root, from this crate's manifest directory.
201pub fn repo_root() -> PathBuf {
202    let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
203    root.canonicalize().unwrap_or(root)
204}
205
206/// A repository file's text, by its path from the root, line endings as LF.
207pub fn read_file(root: &Path, path: &str) -> String {
208    let full = root.join(path);
209    std::fs::read_to_string(&full)
210        .unwrap_or_else(|e| panic!("{}: {e}", full.display()))
211        .replace("\r\n", "\n")
212}
213
214/// What each generated file should hold: the file and its text, every region filled.
215/// The manpages are whole files, after the docs.
216pub fn render_all(root: &Path) -> Result<Vec<(PathBuf, String)>, String> {
217    let read = |path: &str| read_file(root, path);
218    let mut out: Vec<(PathBuf, String)> = Vec::new();
219    for part in GENERATED {
220        let path = root.join(part.file);
221        let content = (part.render)(&read);
222        let current = match out.iter().position(|(p, _)| *p == path) {
223            Some(i) => out.remove(i).1,
224            None if part.region.is_none() => String::new(),
225            None => std::fs::read_to_string(&path)
226                .map_err(|e| format!("{}: {e}", part.file))?
227                // A Windows checkout may turn line endings into CRLF.
228                .replace("\r\n", "\n"),
229        };
230        let text = match part.region {
231            None => content,
232            Some(name) => splice(part.file, &current, name, &content)
233                .map_err(|e| format!("{}: {e}", part.file))?,
234        };
235        out.push((path, text));
236    }
237    for page in crate::man::PAGES {
238        out.push((root.join(page.path()), page.render(&read)));
239    }
240    Ok(out)
241}
242
243/// Write every generated part. Returns the files that changed.
244pub fn write_all(root: &Path) -> Result<Vec<PathBuf>, String> {
245    let mut changed = Vec::new();
246    for (path, text) in render_all(root)? {
247        let old = std::fs::read_to_string(&path).unwrap_or_default();
248        if old.replace("\r\n", "\n") != text {
249            std::fs::write(&path, &text).map_err(|e| format!("{}: {e}", path.display()))?;
250            changed.push(path);
251        }
252    }
253    Ok(changed)
254}
255
256/// The bundled catalog for the docs: its header comment as a quote, the rest as a
257/// block. A block's comment lines read as headings in some outlines, so the header's
258/// rules are prose here.
259pub fn render_public_catalog(text: &str) -> String {
260    let lines: Vec<&str> = text.lines().collect();
261    let header = lines.iter().take_while(|l| l.starts_with('#')).count();
262    let mut out = String::new();
263    for line in &lines[..header] {
264        let said = line.trim_start_matches('#').trim();
265        if said.is_empty() {
266            out.push_str(">\n");
267        } else {
268            out.push_str(&format!("> {said}\n"));
269        }
270    }
271    let body = lines[header..].join("\n");
272    out.push_str(&format!(
273        "\n```toml,output\n{}\n```",
274        body.trim_matches('\n')
275    ));
276    out
277}
278
279/// The family page and heading that describe a format, from `docs/formats/`.
280pub fn format_page(format: FileFormat) -> &'static str {
281    use FileFormat::*;
282    match format {
283        Parquet => "columnar-and-json.md#parquet",
284        Csv | Tsv | Psv => "delimited-text.md#csv-tsv-and-psv",
285        Text => "delimited-text.md#text-and-logs",
286        Json | Jsonl => "columnar-and-json.md#json-and-ndjson",
287        Arrow => "columnar-and-json.md#arrow-ipc",
288        Avro | Orc => "columnar-and-json.md#avro-and-orc",
289        Excel => "columnar-and-json.md#excel",
290        Sqlite => "databases-and-arrays.md#sqlite",
291        Numpy => "databases-and-arrays.md#numpy",
292        Safetensors | Gguf => "model-files.md",
293        Audio => "signals-and-logs.md#audio",
294        Midi => "signals-and-logs.md#midi",
295        Vcd => "signals-and-logs.md#vcd",
296        Nmea | Gpx => "signals-and-logs.md#gps-logs",
297        Ulog | Dataflash => "signals-and-logs.md#flight-logs",
298        Candump => "signals-and-logs.md#can-logs",
299        Fix => "signals-and-logs.md#fix-logs",
300        Sdf => "signals-and-logs.md#sdf",
301        Elf => "signals-and-logs.md#elf",
302        Journal => "signals-and-logs.md#systemd-journal",
303    }
304}
305
306/// A format's name in the docs: its title, or the containers for audio.
307pub fn doc_title(format: FileFormat) -> &'static str {
308    match format {
309        FileFormat::Audio => "WAV, BWF, RF64, AIFF",
310        FileFormat::Text => "Text",
311        _ => format.title(),
312    }
313}
314
315/// A format's name in a list of them: its title, or a plain name for audio and text.
316pub fn short_title(format: FileFormat) -> &'static str {
317    match format {
318        FileFormat::Audio => "WAV/AIFF audio",
319        FileFormat::Text => "plain text",
320        _ => format.title(),
321    }
322}
323
324/// "datui reads 27 formats: Parquet, CSV, ... and binary formats you describe in a
325/// format spec." Counted from the descriptors, so the number cannot go stale.
326pub fn format_count_sentence() -> String {
327    let titles: Vec<&str> = FileFormat::ALL.into_iter().map(short_title).collect();
328    format!(
329        "datui reads {} formats: {}, and binary formats you describe in a format spec.",
330        titles.len(),
331        titles.join(", ")
332    )
333}
334
335/// The formats overview's table: how a file of each format is read, from its
336/// descriptor. One row per format, then an Arrow IPC stream and a format spec.
337pub fn render_formats_markdown() -> String {
338    let mut out = String::from(
339        "| Format | `--format` | Extensions | Read | Compressed | HTTP(S) | In a bucket | Bucket prefix |\n\
340         |---|---|---|---|---|---|---|---|\n",
341    );
342    let said = |mode: Option<crate::formats::ReadMode>| mode.map_or("no", |m| m.label());
343    for format in FileFormat::ALL {
344        let d = format.descriptor();
345        let mut names: Vec<String> = d.extensions.iter().map(|e| format!("`.{e}`")).collect();
346        names.extend(d.name_endings.iter().map(|n| format!("`{n}`")));
347        let extensions = if names.is_empty() {
348            "none: by content".to_string()
349        } else {
350            names.join(", ")
351        };
352        out.push_str(&format!(
353            "| [{}]({}) | `{}` | {} | {} | {} | {} | {} | {} |\n",
354            doc_title(format),
355            format_page(format),
356            format.name(),
357            extensions,
358            said(format.read_mode(Stored::Plain)),
359            said(format.read_mode(Stored::Compressed { in_memory: false })),
360            format.http_file().label(),
361            format.bucket_object(Stored::Plain).label(),
362            format
363                .bucket_prefix(Stored::Plain)
364                .map_or("no", RemoteRead::label),
365        ));
366    }
367    let arrow = FileFormat::Arrow;
368    out.push_str(&format!(
369        "| [Arrow IPC stream](columnar-and-json.md#arrow-ipc) | `arrow` | as Arrow IPC | {} | {} | {} | {} | {} |\n",
370        said(arrow.read_mode(Stored::Stream)),
371        said(arrow.read_mode(Stored::Compressed { in_memory: false })),
372        arrow.http_file().label(),
373        arrow.bucket_object(Stored::Stream).label(),
374        arrow.bucket_prefix(Stored::Stream).map_or("no", RemoteRead::label),
375    ));
376    let spec = crate::FormatChoice::Spec("a.spec".into());
377    out.push_str(&format!(
378        "| [Format spec](format-specs.md) | its name | its `match` | {} | {} | {} | {} | {} |\n",
379        said(spec.read_mode(Stored::Plain)),
380        said(spec.read_mode(Stored::Compressed { in_memory: false })),
381        spec.http_file().label(),
382        spec.bucket_object(Stored::Plain).label(),
383        spec.bucket_prefix(Stored::Plain)
384            .map_or("no", RemoteRead::label),
385    ));
386    out
387}
388
389/// The keywords `datui.view()` and `DatuiOptions` take, from the option registry: the
390/// open's own options, then the config keys', then `config`.
391pub fn render_python_options_markdown() -> String {
392    use clap::CommandFactory;
393    let cmd = crate::Args::command();
394    let flag_help = |flag: &str| -> String {
395        cmd.get_arguments()
396            .find(|a| a.get_long() == Some(flag))
397            .and_then(|a| a.get_help())
398            .map(|h| h.to_string())
399            .unwrap_or_default()
400    };
401    let cell = |s: &str| s.replace('|', "\\|").replace('\n', " ");
402    let mut out =
403        String::from("| Keyword | Takes | Command line | What it does |\n|---|---|---|---|\n");
404    for open in settings::OPEN {
405        out.push_str(&format!(
406            "| `{}` | {} | `--{}` | {} |\n",
407            open.kwarg,
408            open.kind.describe(),
409            open.flag,
410            cell(&flag_help(open.flag)),
411        ));
412    }
413    for setting in settings::SETTINGS.iter().filter(|s| s.kwarg.is_some()) {
414        let line = match setting.flag {
415            Some(flag) => format!("`--{flag}`"),
416            None => format!("`-c {}=...`", setting.key),
417        };
418        out.push_str(&format!(
419            "| `{}` | {} | {} | {} |\n",
420            setting.kwarg.unwrap_or_default(),
421            setting.kind.describe(),
422            line,
423            cell(setting.doc),
424        ));
425    }
426    out.push_str(
427        "| `config` | dict | `-c KEY=VALUE` | Any config key to its value, as `-c` sets it: `config={\"display.row_numbers\": True}` |\n",
428    );
429    out
430}
431
432#[cfg(test)]
433mod tests {
434    use super::*;
435
436    /// The committed docs are what the code renders. Run `gen_docs write` (or
437    /// `.venv/bin/python scripts/docs/generate_command_line_options.py --all`) after
438    /// changing a flag, a setting, a format, a key or an environment variable.
439    #[test]
440    fn the_generated_docs_are_current() {
441        let root = repo_root();
442        let stale: Vec<String> = render_all(&root)
443            .expect("every generated region has its markers")
444            .into_iter()
445            .filter(|(path, text)| {
446                std::fs::read_to_string(path)
447                    .map(|t| t.replace("\r\n", "\n"))
448                    .ok()
449                    .as_deref()
450                    != Some(text.as_str())
451            })
452            .map(|(path, _)| path.display().to_string())
453            .collect();
454        assert!(
455            stale.is_empty(),
456            "stale: {}. Run `cargo run -p datui-cli --bin gen_docs -- write`",
457            stale.join(", ")
458        );
459    }
460
461    #[test]
462    fn a_region_is_replaced_between_its_markers() {
463        let text = "a\n<!-- generated: x -->\nold\n<!-- end generated: x -->\nb\n";
464        assert_eq!(
465            splice("a.md", text, "x", "new").unwrap(),
466            "a\n<!-- generated: x -->\nnew\n<!-- end generated: x -->\nb\n"
467        );
468        assert!(splice("a.md", text, "y", "new").is_err());
469        let toml = "a\n# generated: x\nold\n# end generated: x\nb\n";
470        assert_eq!(
471            splice("a.toml", toml, "x", "new").unwrap(),
472            "a\n# generated: x\nnew\n# end generated: x\nb\n"
473        );
474    }
475
476    #[test]
477    fn the_format_count_counts_every_format() {
478        let sentence = format_count_sentence();
479        assert!(sentence.starts_with(&format!("datui reads {} formats", FileFormat::ALL.len())));
480        assert_eq!(sentence.matches(", ").count(), FileFormat::ALL.len());
481    }
482}