Skip to main content

isoloom_spec/reference/
markdown.rs

1//! The reference as Markdown: a section per part of the spec, an entry per field with its type,
2//! default, allowed values, limits and the schema's examples.
3
4use std::fmt::{self, Display, Formatter};
5
6use super::{Field, fields};
7
8/// The page's opening: what it is, where it comes from, how to read a path.
9const INTRO: &str = "# Spec reference
10
11<!-- Generated by `isoloom docs --spec`. Edit the model's doc comments (the schema), then regenerate. -->
12
13Every field of `isoloom.yml`, read from its JSON schema (`isoloom schema`, which editors use for
14completion and hover). In a path, `*` stands for a name you choose (a network, a machine) and `[]`
15for each item of a list.
16";
17
18/// The whole page.
19#[must_use]
20pub fn markdown() -> String {
21    Page(&fields(&crate::schema::schema())).to_string()
22}
23
24/// The page of every field, written section by section.
25struct Page<'a>(&'a [Field]);
26
27impl Display for Page<'_> {
28    fn fmt(&self, md: &mut Formatter<'_>) -> fmt::Result {
29        let sections = sections(self.0);
30        md.write_str(INTRO)?;
31        md.write_str("\n## Sections\n\n")?;
32        for (title, _) in &sections {
33            writeln!(md, "- [{}](#{})", heading(title), slug(&heading(title)))?;
34        }
35        for (title, members) in &sections {
36            write!(md, "\n## {}\n", heading(title))?;
37            for f in members {
38                entry(md, f)?;
39            }
40        }
41        Ok(())
42    }
43}
44
45/// A section's heading: the part of the spec it covers.
46fn heading(section: &str) -> String {
47    if section.is_empty() { "Top level".to_owned() } else { format!("`{section}`") }
48}
49
50/// The fields by section, in the schema's order: a top-level field with fields of its own
51/// opens a section (and a machine's `docker`, `vm` and `external` each one), the others are
52/// the top level's.
53fn sections(all: &[Field]) -> Vec<(String, Vec<&Field>)> {
54    let mut out: Vec<(String, Vec<&Field>)> = vec![(String::new(), Vec::new())];
55    for f in all {
56        let key = section_of(&f.path, all);
57        match out.iter_mut().find(|(k, _)| *k == key) {
58            Some((_, members)) => members.push(f),
59            None => out.push((key, vec![f])),
60        }
61    }
62    out.retain(|(_, members)| !members.is_empty());
63    out
64}
65
66/// The section a field belongs to.
67fn section_of(path: &str, all: &[Field]) -> String {
68    for part in ["docker", "vm", "external"] {
69        let sub = format!("machines.*.{part}");
70        if path == sub || path.starts_with(&format!("{sub}.")) {
71            return sub;
72        }
73    }
74    let top = path.split(['.', '[']).next().unwrap_or(path);
75    let nested = all.iter().any(|f| is_inside(&f.path, top));
76    if nested { top.to_owned() } else { String::new() }
77}
78
79/// Whether `path` is a field inside `parent`.
80fn is_inside(path: &str, parent: &str) -> bool {
81    path.strip_prefix(parent).is_some_and(|rest| rest.starts_with('.') || rest.starts_with('['))
82}
83
84/// One field's entry.
85fn entry(md: &mut Formatter<'_>, f: &Field) -> fmt::Result {
86    write!(md, "\n### `{}`\n\n", f.path)?;
87    if !f.description.is_empty() {
88        write!(md, "{}\n\n", f.description)?;
89    }
90    writeln!(md, "- **Type:** {}", f.kind)?;
91    if let Some(d) = &f.default {
92        writeln!(md, "- **Default:** `{d}`")?;
93    }
94    allowed(md, &f.allowed)?;
95    if !f.limits.is_empty() {
96        writeln!(md, "- **Limits:** {}", f.limits.join("; "))?;
97    }
98    if !f.members.is_empty() {
99        let names: Vec<String> = f.members.iter().map(|m| format!("`{m}`")).collect();
100        writeln!(md, "- **Holds:** {}, each as on a machine", names.join(", "))?;
101    }
102    for e in &f.examples {
103        writeln!(md, "- **Example:** `{e}`")?;
104    }
105    Ok(())
106}
107
108/// The allowed values: inline when they need no explanation, else one per line.
109fn allowed(md: &mut Formatter<'_>, values: &[(String, String)]) -> fmt::Result {
110    if values.is_empty() {
111        return Ok(());
112    }
113    if values.iter().all(|(_, meaning)| meaning.is_empty()) {
114        let list: Vec<String> = values.iter().map(|(v, _)| format!("`{v}`")).collect();
115        return writeln!(md, "- **Allowed:** {}", list.join(", "));
116    }
117    md.write_str("- **Allowed:**\n")?;
118    for (v, meaning) in values {
119        writeln!(md, "  - `{v}`: {}", meaning.replace('\n', " "))?;
120    }
121    Ok(())
122}
123
124/// The anchor GitHub gives a heading: lower case, spaces as `-`, other punctuation dropped.
125#[must_use]
126pub fn slug(heading: &str) -> String {
127    heading
128        .to_lowercase()
129        .chars()
130        .filter_map(|c| match c {
131            ' ' => Some('-'),
132            c if c.is_alphanumeric() || c == '-' || c == '_' => Some(c),
133            _ => None,
134        })
135        .collect()
136}
137
138#[cfg(test)]
139mod tests {
140    use serde_json::json;
141
142    use super::*;
143
144    #[test]
145    fn a_field_with_fields_of_its_own_and_each_machine_implementation_open_a_section() {
146        let all = fields(&json!({
147            "properties": {
148                "version": { "type": "integer" },
149                "versions": { "type": "array", "items": { "type": "integer" } },
150                "machines": {
151                    "type": "object",
152                    "additionalProperties": {
153                        "properties": { "os": { "type": "string" }, "docker": { "properties": { "image": { "type": "string" } } } }
154                    }
155                }
156            }
157        }));
158        let read: Vec<(String, Vec<&str>)> = sections(&all)
159            .into_iter()
160            .map(|(title, members)| (title, members.iter().map(|f| f.path.as_str()).collect()))
161            .collect();
162        assert_eq!(
163            read,
164            [
165                (String::new(), vec!["version", "versions"]),
166                ("machines".to_owned(), vec!["machines", "machines.*.os"]),
167                ("machines.*.docker".to_owned(), vec!["machines.*.docker", "machines.*.docker.image"]),
168            ],
169            "`versions` isn't inside `version`"
170        );
171    }
172}