Skip to main content

xtask/
manifest_schema.rs

1//! #2096: the JSON Schema for `bynk.toml`, generated from the manifest's Rust
2//! types.
3//!
4//! The tables come from the crates that own them: `[fmt]` from `bynk-fmt`'s
5//! `RawFmt` (the struct that reads it), the rest from
6//! `bynk_project::manifest_schema`. The table list is
7//! [`bynk_project::MANIFEST_TABLES`] and [`bynk_project::MANIFEST_MAP_TABLES`],
8//! the ones `check_manifest_str` refuses against, so a table added there without a schema fails [`generate`], and the
9//! test holds every table's keys to it.
10//!
11//! The schema is committed at [`SCHEMA_PATH`], under the docs site's `public/`
12//! directory, so the site serves it at [`SCHEMA_URL`]. `cargo xtask
13//! manifest-schema` rewrites it; `tests/manifest_schema.rs` fails when it is
14//! stale.
15
16use std::path::Path;
17
18use schemars::generate::SchemaSettings;
19use serde_json::{Map, Value, json};
20
21/// Where the schema is committed, relative to the repo root.
22pub const SCHEMA_PATH: &str = "site/public/schemas/bynk.toml.json";
23
24/// Where the docs site serves it: the schema's `$id`, the URL the VS Code
25/// extension associates with `bynk.toml`, and the one a `#:schema` directive
26/// names.
27pub const SCHEMA_URL: &str = "https://bynk-lang.org/schemas/bynk.toml.json";
28
29/// The manifest reference page, linked from each table's hover.
30const DOCS_URL: &str = "https://bynk-lang.org/docs/manifest/";
31
32/// The whole `bynk.toml` schema, keys sorted so the committed text is stable.
33pub fn generate() -> Value {
34    // Draft 07 is the dialect TOML language servers (taplo) support best, and
35    // inlining keeps each table's description beside its key: taplo ignores
36    // everything next to a `$ref`.
37    let mut generator = SchemaSettings::draft07()
38        .with(|s| s.inline_subschemas = true)
39        .into_generator();
40    let mut properties = Map::new();
41    let tables = bynk_project::MANIFEST_TABLES
42        .iter()
43        .chain(bynk_project::MANIFEST_MAP_TABLES);
44    for (table, _) in tables {
45        let schema = match *table {
46            "fmt" => bynk_fmt::fmt_table_schema(&mut generator),
47            other => bynk_project::manifest_schema::table_schema(other, &mut generator)
48                .unwrap_or_else(|| panic!("`[{other}]` is in MANIFEST_TABLES but has no schema")),
49        };
50        let mut schema = without_null(schema.to_value());
51        if let Value::Object(fields) = &mut schema {
52            // The Rust type's name means nothing to someone editing a manifest.
53            fields.remove("title");
54            fields.insert(
55                "x-taplo".into(),
56                json!({ "links": { "key": format!("{DOCS_URL}#{table}") } }),
57            );
58        }
59        properties.insert((*table).to_string(), schema);
60    }
61    let root = json!({
62        "$schema": "http://json-schema.org/draft-07/schema#",
63        "$id": SCHEMA_URL,
64        "title": "bynk.toml",
65        "description": "The Bynk project manifest. Every table and key is optional. \
66            Generated from the toolchain's manifest types by `cargo xtask manifest-schema`.",
67        "type": "object",
68        "additionalProperties": false,
69        "properties": properties,
70        "x-taplo": { "links": { "key": DOCS_URL } },
71    });
72    sorted(root)
73}
74
75/// The schema as committed: pretty-printed, with a trailing newline.
76pub fn render() -> String {
77    let mut text = serde_json::to_string_pretty(&generate()).expect("a JSON value serialises");
78    text.push('\n');
79    text
80}
81
82/// Write the schema to [`SCHEMA_PATH`] under `root`.
83pub fn write(root: &Path) -> std::io::Result<()> {
84    let path = root.join(SCHEMA_PATH);
85    if let Some(dir) = path.parent() {
86        std::fs::create_dir_all(dir)?;
87    }
88    std::fs::write(path, render())
89}
90
91/// `schema` with the `null` that schemars allows for an `Option` field taken
92/// out. TOML has no null: an optional key is simply absent, and `key = null`
93/// doesn't parse, so offering it would only mislead. A `type` list loses its
94/// `"null"`, and an `anyOf` left with one branch is folded into its parent
95/// (the parent's own description wins over the branch's).
96fn without_null(schema: Value) -> Value {
97    let Value::Object(mut fields) = schema else {
98        return match schema {
99            Value::Array(items) => Value::Array(items.into_iter().map(without_null).collect()),
100            other => other,
101        };
102    };
103    if let Some(Value::Array(types)) = fields.get_mut("type") {
104        types.retain(|t| t != "null");
105        if types.len() == 1 {
106            let only = types.remove(0);
107            fields.insert("type".into(), only);
108        }
109    }
110    if let Some(Value::Array(branches)) = fields.get_mut("anyOf") {
111        branches.retain(|b| b != &json!({ "type": "null" }));
112        // Fold only a lone object branch; anything else stays as it is, rather
113        // than being taken out of the array and dropped.
114        if let [Value::Object(branch)] = branches.as_slice() {
115            let branch = branch.clone();
116            fields.remove("anyOf");
117            for (key, value) in branch {
118                fields.entry(key).or_insert(value);
119            }
120        }
121    }
122    Value::Object(
123        fields
124            .into_iter()
125            .map(|(k, v)| (k, without_null(v)))
126            .collect(),
127    )
128}
129
130/// `value` with every object's keys in sorted order, whichever map type
131/// `serde_json` was built with (`preserve_order` keeps insertion order).
132fn sorted(value: Value) -> Value {
133    match value {
134        Value::Object(map) => {
135            let mut entries: Vec<(String, Value)> = map.into_iter().collect();
136            entries.sort_by(|a, b| a.0.cmp(&b.0));
137            Value::Object(entries.into_iter().map(|(k, v)| (k, sorted(v))).collect())
138        }
139        Value::Array(items) => Value::Array(items.into_iter().map(sorted).collect()),
140        other => other,
141    }
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147
148    #[test]
149    fn without_null_drops_null_from_a_type_list() {
150        assert_eq!(
151            without_null(json!({ "type": ["string", "null"] })),
152            json!({ "type": "string" })
153        );
154        assert_eq!(
155            without_null(json!({ "type": ["string", "integer", "null"] })),
156            json!({ "type": ["string", "integer"] })
157        );
158    }
159
160    #[test]
161    fn without_null_folds_a_lone_object_branch_keeping_the_parents_fields() {
162        let folded = without_null(json!({
163            "description": "outer",
164            "anyOf": [{ "type": "string", "description": "inner" }, { "type": "null" }],
165        }));
166        assert_eq!(folded, json!({ "description": "outer", "type": "string" }));
167    }
168
169    #[test]
170    fn without_null_keeps_an_any_of_it_cannot_fold() {
171        // Two branches left: kept as a choice.
172        let two = json!({ "anyOf": [{ "type": "string" }, { "type": "integer" }] });
173        assert_eq!(without_null(two.clone()), two);
174        // One non-object branch (`true` is a valid schema): kept, not dropped.
175        assert_eq!(
176            without_null(json!({ "anyOf": [true, { "type": "null" }] })),
177            json!({ "anyOf": [true] })
178        );
179    }
180}