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