Skip to main content

isoloom_spec/
reference.rs

1//! The spec reference, `docs/spec.md`: every field of `isoloom.yml` read from its JSON schema
2//! (type, default, allowed values, limits, description, examples). The schema is the code
3//! itself, so the page can't drift: a test fails when the committed page differs from what
4//! `isoloom docs --spec` prints.
5
6mod markdown;
7mod text;
8
9use serde_json::{Map, Value};
10
11pub use markdown::{markdown, slug};
12
13/// Fields whose own fields repeat a machine's: listed by name only, since the machine's
14/// section documents them.
15const SHALLOW: &[&str] = &["common", "groups"];
16
17/// How deep the walk goes, a guard against a schema that refers to itself.
18const MAX_DEPTH: usize = 10;
19
20/// One field of the spec as the reference shows it.
21#[derive(Debug, Clone, Default, PartialEq, Eq)]
22pub struct Field {
23    /// Its path: `*` for a name the author chooses, `[]` for the items of a list
24    /// (`machines.*.ports[].port`).
25    pub path: String,
26    /// Its type in words (`string`, `list of mappings`, `map of name to integer`).
27    pub kind: String,
28    /// What it is for, from the model's doc comment.
29    pub description: String,
30    /// Its default value as JSON, when the schema states one worth showing.
31    pub default: Option<String>,
32    /// The values it accepts, each with what it means, when it is a closed set.
33    pub allowed: Vec<(String, String)>,
34    /// Ranges and patterns the schema enforces beyond the type.
35    pub limits: Vec<String>,
36    /// Examples the schema carries.
37    pub examples: Vec<String>,
38    /// For a shallow field (`common`, `groups`), the names of the fields it holds.
39    pub members: Vec<String>,
40}
41
42/// Every field of `schema` (the output of [`crate::schema::schema`]), in the schema's
43/// order: a field, then the fields inside it.
44pub fn fields(schema: &Value) -> Vec<Field> {
45    let empty = Map::new();
46    let walker = Walker {
47        defs: schema.get("$defs").and_then(Value::as_object).unwrap_or(&empty),
48    };
49    let mut out = Vec::new();
50    walker.children(schema, "", 0, &mut out);
51    out
52}
53
54/// Walks the schema, following `$ref`s into its definitions.
55struct Walker<'a> {
56    defs: &'a Map<String, Value>,
57}
58
59impl<'a> Walker<'a> {
60    /// The node a `$ref` points to (followed until there is none), or the node itself.
61    fn resolve(&self, node: &'a Value) -> &'a Value {
62        let mut node = node;
63        for _ in 0..MAX_DEPTH {
64            let Some(name) = node.get("$ref").and_then(Value::as_str).and_then(|r| r.strip_prefix("#/$defs/")) else {
65                break;
66            };
67            match self.defs.get(name) {
68                Some(next) => node = next,
69                None => break,
70            }
71        }
72        node
73    }
74
75    /// The property maps `node` can hold: its own, or each of its alternatives' (a field that
76    /// is a scalar or a mapping, like `gpus`).
77    fn objects(&self, node: &'a Value, depth: usize) -> Vec<&'a Map<String, Value>> {
78        let node = self.resolve(node);
79        if let Some(p) = node.get("properties").and_then(Value::as_object) {
80            return vec![p];
81        }
82        if depth >= MAX_DEPTH {
83            return Vec::new();
84        }
85        text::variants(node).iter().flat_map(|v| self.objects(v, depth + 1)).collect()
86    }
87
88    /// The fields inside `node`, at `path`: its properties, the properties of what its map
89    /// holds (at `path.*`), or of its list's items (at `path[]`).
90    fn children(&self, node: &'a Value, path: &str, depth: usize, out: &mut Vec<Field>) {
91        if depth >= MAX_DEPTH {
92            return;
93        }
94        let node = self.resolve(node);
95        let (inner, at) = match (text::map_value(node), node.get("items")) {
96            (Some(value), _) => (value, format!("{path}.*")),
97            (_, Some(items)) => (items, format!("{path}[]")),
98            _ => (node, path.to_owned()),
99        };
100        for props in self.objects(inner, depth) {
101            for (name, child) in props {
102                let child_path = if at.is_empty() { name.clone() } else { format!("{at}.{name}") };
103                if out.iter().any(|f| f.path == child_path) {
104                    continue;
105                }
106                self.field(child, &child_path, depth + 1, out);
107            }
108        }
109    }
110
111    /// The field at `path`, then the fields inside it.
112    fn field(&self, node: &'a Value, path: &str, depth: usize, out: &mut Vec<Field>) {
113        let target = self.resolve(node);
114        let shallow = SHALLOW.contains(&path);
115        let pick = |key: &str| node.get(key).or_else(|| target.get(key));
116        let description = pick("description").and_then(Value::as_str).map(text::prose).unwrap_or_default();
117        let mut allowed = text::allowed(self, node);
118        // A single value whose meaning is the field's own description says it once.
119        if let [(_, meaning)] = allowed.as_mut_slice()
120            && *meaning == description
121        {
122            meaning.clear();
123        }
124        out.push(Field {
125            kind: text::kind(self, node, depth),
126            description,
127            default: pick("default").and_then(text::default),
128            allowed,
129            limits: text::limits(self, node),
130            examples: pick("examples")
131                .and_then(Value::as_array)
132                .map(|e| e.iter().map(text::json).collect())
133                .unwrap_or_default(),
134            members: if shallow { self.member_names(node, depth) } else { Vec::new() },
135            path: path.to_owned(),
136        });
137        if !shallow {
138            self.children(node, path, depth, out);
139        }
140    }
141
142    /// The names of the fields a shallow field holds (in its map's values, for `groups`).
143    fn member_names(&self, node: &'a Value, depth: usize) -> Vec<String> {
144        let node = self.resolve(node);
145        let inner = text::map_value(node).unwrap_or(node);
146        self.objects(inner, depth).into_iter().flat_map(|p| p.keys().cloned()).collect()
147    }
148}
149
150#[cfg(test)]
151mod tests;