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