From 67fdeecf702edb9d7fa2db0eaf82820dce68d350 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Thu, 2 Jul 2026 13:04:22 +0200 Subject: [PATCH] config: add a generator for the configuration file reference Adds a second bin target to mas-config which renders docs/reference/configuration.md from the config JSON schema: doc comments become the YAML comments, schemars examples/defaults become the example values, and an x-doc schema extension (commented/skip/yaml) covers the idioms that need hand-authoring. Wired into misc/update.sh so the existing CI freshness check keeps the reference in sync. --- Cargo.lock | 1 + crates/config/Cargo.toml | 6 + crates/config/src/bin/docs.rs | 988 ++++++++++++++++++++++++++++++++++ misc/update.sh | 3 + 4 files changed, 998 insertions(+) create mode 100644 crates/config/src/bin/docs.rs diff --git a/Cargo.lock b/Cargo.lock index 1ca66a601..457929860 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3247,6 +3247,7 @@ dependencies = [ "serde", "serde_json", "serde_with", + "serde_yaml", "tokio", "tracing", "ulid", diff --git a/crates/config/Cargo.toml b/crates/config/Cargo.toml index 4882647a6..49a5e7d20 100644 --- a/crates/config/Cargo.toml +++ b/crates/config/Cargo.toml @@ -5,6 +5,7 @@ [package] name = "mas-config" +default-run = "schema" version.workspace = true authors.workspace = true edition.workspace = true @@ -34,6 +35,7 @@ rustls-pki-types.workspace = true schemars.workspace = true serde_json.workspace = true serde_with.workspace = true +serde_yaml.workspace = true serde.workspace = true tokio.workspace = true tracing.workspace = true @@ -51,3 +53,7 @@ dist = [] [[bin]] name = "schema" doc = false + +[[bin]] +name = "docs" +doc = false diff --git a/crates/config/src/bin/docs.rs b/crates/config/src/bin/docs.rs new file mode 100644 index 000000000..b7a186876 --- /dev/null +++ b/crates/config/src/bin/docs.rs @@ -0,0 +1,988 @@ +// Copyright 2026 Element Creations Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. + +//! Generates `docs/reference/configuration.md` from the config JSON schema. +//! +//! The renderer walks the JSON schema (as a [`serde_json::Value`], not the +//! typed schemars API) and emits one annotated YAML example block per config +//! section, in the style of the hand-written reference document. + +use schemars::generate::SchemaSettings; +use serde_json::{Map, Value}; + +/// Order in which sections are rendered. Sections present in the schema but +/// missing from this list are appended at the end and produce a warning. +const SECTION_ORDER: &[&str] = &[ + "http", + "database", + "matrix", + "templates", + "clients", + "secrets", + "passwords", + "account", + "captcha", + "policy", + "rate_limiting", + "telemetry", + "email", + "upstream_oauth2", + "branding", + "oauth", + "experimental", +]; + +/// Fields that are pulled out of their parent's YAML block and rendered as +/// their own `### ` subsection. Each entry is `section.field`. +const HOISTED: &[&str] = &["http.listeners", "upstream_oauth2.providers"]; + +fn main() { + let generator = SchemaSettings::draft07().into_generator(); + let schema = generator.into_root_schema_for::(); + let schema: Value = + serde_json::to_value(&schema).expect("Failed to convert schema to serde_json::Value"); + + let mut renderer = Renderer::new(&schema); + let output = renderer.render(); + + print!("{output}"); + + for warning in &renderer.warnings { + eprintln!("{warning}"); + } +} + +struct Renderer { + /// Top-level properties of the schema (the sections). + properties: Map, + /// The `definitions` map, used to resolve `$ref`s. + definitions: Map, + /// Warnings collected while rendering, printed to stderr at the end. + warnings: Vec, +} + +/// A key/value union variant that carries a `const` tag, i.e. a string enum. +struct EnumInfo { + /// The list of `(value, description)` pairs. + values: Vec<(String, Option)>, + /// Whether at least one variant carries a description. + has_descriptions: bool, +} + +impl Renderer { + fn new(schema: &Value) -> Self { + let properties = schema + .get("properties") + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(); + let definitions = schema + .get("definitions") + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(); + Self { + properties, + definitions, + warnings: Vec::new(), + } + } + + fn warn(&mut self, message: String) { + self.warnings.push(message); + } + + // --- Schema resolution ------------------------------------------------- + + /// Follow `$ref`s and unwrap single-element `allOf`s until a fixed point + /// is reached. Keys on the referring site override the target's. + fn resolve(&self, node: &Value) -> Value { + let mut node = node.clone(); + loop { + let Some(obj) = node.as_object() else { + return node; + }; + + // Unwrap single-element `allOf`, merging sibling keys (ref-site wins). + if let Some(all_of) = obj.get("allOf").and_then(Value::as_array) + && all_of.len() == 1 + { + let mut merged = all_of[0].as_object().cloned().unwrap_or_default(); + for (key, value) in obj { + if key != "allOf" { + merged.insert(key.clone(), value.clone()); + } + } + node = Value::Object(merged); + continue; + } + + // Resolve `$ref` into `definitions`, letting ref-site keys win. + if let Some(reference) = obj.get("$ref").and_then(Value::as_str) { + let name = reference.rsplit('/').next().unwrap_or(reference); + let mut merged = self + .definitions + .get(name) + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(); + for (key, value) in obj { + if key != "$ref" { + merged.insert(key.clone(), value.clone()); + } + } + node = Value::Object(merged); + continue; + } + + return node; + } + } + + /// The description of the *definition* a node refers to, ignoring any + /// description on the referring site. `None` when the node is not a + /// reference or the target has no description. + fn definition_description(&self, node: &Value) -> Option { + let mut node = node; + // Look through single-element `allOf` wrappers. + while let Some(all_of) = node.get("allOf").and_then(Value::as_array) + && let [inner] = all_of.as_slice() + { + node = inner; + } + let reference = node.get("$ref")?.as_str()?; + let name = reference.rsplit('/').next().unwrap_or(reference); + let description = self.definitions.get(name)?.get("description")?.as_str()?; + Some(clean_description(description)).filter(|d| !d.is_empty()) + } + + /// Resolve a node and, if it represents an `Option`, unwrap it to `T`. + /// + /// Handles both `type: ["X", "null"]` and `anyOf: [T, {type: "null"}]`. + fn strip_null(&self, node: &Value) -> Value { + let node = self.resolve(node); + let Some(obj) = node.as_object() else { + return node; + }; + + // `type: ["X", "null"]` → `type: "X"` + if let Some(types) = obj.get("type").and_then(Value::as_array) + && types.iter().any(|t| t == "null") + { + let rest: Vec = types.iter().filter(|t| *t != "null").cloned().collect(); + let mut merged = obj.clone(); + match rest.as_slice() { + [single] => merged.insert("type".to_owned(), single.clone()), + _ => merged.insert("type".to_owned(), Value::Array(rest)), + }; + return Value::Object(merged); + } + + // `anyOf: [T, {type: "null"}]` → `T` (with ref-site keys merged in) + let null = serde_json::json!({ "type": "null" }); + if let Some(variants) = obj.get("anyOf").and_then(Value::as_array) + && variants.len() == 2 + && variants[1] == null + { + let inner = self.resolve(&variants[0]); + let mut merged = inner.as_object().cloned().unwrap_or_default(); + for (key, value) in obj { + if key != "anyOf" { + merged.insert(key.clone(), value.clone()); + } + } + return Value::Object(merged); + } + + node + } + + // --- `x-doc` extension -------------------------------------------------- + + fn x_doc(node: &Value) -> Option<&Map> { + node.get("x-doc").and_then(Value::as_object) + } + + fn x_doc_commented(node: &Value) -> bool { + Self::x_doc(node) + .and_then(|d| d.get("commented")) + .and_then(Value::as_bool) + .unwrap_or(false) + } + + fn x_doc_yaml(node: &Value) -> Option { + Self::x_doc(node) + .and_then(|d| d.get("yaml")) + .and_then(Value::as_str) + .map(ToOwned::to_owned) + } + + fn x_doc_skip(node: &Value) -> bool { + Self::x_doc(node) + .and_then(|d| d.get("skip")) + .and_then(Value::as_bool) + .unwrap_or(false) + } + + // --- Rendering --------------------------------------------------------- + + fn render(&mut self) -> String { + let mut lines: Vec = vec![ + "# Configuration file reference".to_owned(), + String::new(), + "" + .to_owned(), + String::new(), + ]; + + // Figure out the section order: known sections first, then any extra. + let mut order: Vec = SECTION_ORDER + .iter() + .filter(|name| self.properties.contains_key(**name)) + .map(|name| (*name).to_owned()) + .collect(); + let mut extra: Vec = self + .properties + .keys() + .filter(|name| !SECTION_ORDER.contains(&name.as_str())) + .cloned() + .collect(); + extra.sort(); + for name in &extra { + self.warn(format!( + "section not in SECTION_ORDER, appended at the end: {name}" + )); + } + order.extend(extra); + + for name in order { + self.render_section(&mut lines, &name); + } + + finalize(&lines) + } + + fn render_section(&mut self, lines: &mut Vec, name: &str) { + let raw_node = self.properties.get(name).cloned().unwrap_or(Value::Null); + let node = self.strip_null(&raw_node); + + lines.push(format!("## `{name}`")); + lines.push(String::new()); + + // Section description, split around an optional `` marker. + // + // Prefer the section *struct*'s own doc comment over the `RootConfig` + // field's: the resolver lets ref-site keys win (which is right for + // fields), but section structs carry the full reference prose while + // the `RootConfig` field docs are one-line summaries. + let description = self + .definition_description(&raw_node) + .or_else(|| { + node.get("description") + .and_then(Value::as_str) + .map(clean_description) + }) + .unwrap_or_default(); + let (before, after) = split_more(&description); + if !before.is_empty() { + lines.push(before); + lines.push(String::new()); + } + + // Which of this section's fields are hoisted into their own subsection. + let hoisted: Vec = HOISTED + .iter() + .filter_map(|h| h.strip_prefix(&format!("{name}."))) + .map(ToOwned::to_owned) + .collect(); + let hoisted_refs: Vec<&str> = hoisted.iter().map(String::as_str).collect(); + + // Emit the section's own YAML block, unless everything is hoisted. + let has_body = section_has_body(&node, &hoisted_refs); + if has_body { + lines.push("```yaml".to_owned()); + let body = self.emit_value(name, &node, 0, name, &hoisted_refs, None); + lines.extend(body); + lines.push("```".to_owned()); + lines.push(String::new()); + } + + if !after.is_empty() { + lines.push(after); + lines.push(String::new()); + } + + // Emit hoisted subsections. + for field in &hoisted { + self.render_hoisted(lines, name, &node, field); + } + } + + fn render_hoisted( + &mut self, + lines: &mut Vec, + section: &str, + node: &Value, + field: &str, + ) { + let Some(field_node) = node + .get("properties") + .and_then(Value::as_object) + .and_then(|props| props.get(field)) + else { + return; + }; + let field_node = self.strip_null(field_node); + + lines.push(format!("### `{section}.{field}`")); + lines.push(String::new()); + + // Field description, split around an optional `` marker. + let (before, after) = split_more( + field_node + .get("description") + .and_then(Value::as_str) + .map(clean_description) + .as_deref() + .unwrap_or_default(), + ); + if !before.is_empty() { + lines.push(before); + lines.push(String::new()); + } + + lines.push("```yaml".to_owned()); + lines.push(format!("{section}:")); + let path = format!("{section}.{field}"); + let body = self.emit_value(field, &field_node, 2, &path, &[], None); + lines.extend(body); + lines.push("```".to_owned()); + lines.push(String::new()); + + if !after.is_empty() { + lines.push(after); + lines.push(String::new()); + } + } + + /// Emit a single field: its comment lines followed by its value. + /// + /// A field marked with `x-doc.skip` emits nothing (its content is + /// hand-authored in a sibling's `x-doc.yaml` block). A field with + /// `x-doc.yaml` is entirely hand-authored: the raw YAML (comments and key + /// line included) replaces the whole emission. + fn emit_field( + &mut self, + name: &str, + node: &Value, + indent: usize, + path: &str, + inherited: Option<&Value>, + ) -> Vec { + let resolved = self.strip_null(node); + if Self::x_doc_skip(&resolved) { + return Vec::new(); + } + if let Some(yaml) = Self::x_doc_yaml(&resolved) { + return reindent(&yaml, indent); + } + let commented = Self::x_doc_commented(&resolved); + + let mut out = comment_lines(&resolved, indent); + let value = self.emit_value(name, &resolved, indent, path, &[], inherited); + if commented { + out.extend(value.into_iter().map(|line| comment_out(&line))); + } else { + out.extend(value); + } + out + } + + /// Emit the value of a field: `name:` and everything below it, unindented + /// by `indent` spaces. `skip` names direct children to omit (used to hoist + /// subsections out of a section's body). + fn emit_value( + &mut self, + name: &str, + resolved: &Value, + indent: usize, + path: &str, + skip: &[&str], + inherited: Option<&Value>, + ) -> Vec { + let pad = " ".repeat(indent); + + // An explicit example wins over recursing into the value's structure: + // it renders as one YAML block and short-circuits descending into + // (potentially huge) sub-schemas like the JSON Web Key types. + if resolved + .get("examples") + .and_then(Value::as_array) + .is_some_and(|examples| !examples.is_empty()) + { + return self.emit_leaf(name, resolved, indent, path, inherited); + } + + // Object with properties → recurse into each child, handing each the + // matching part of this object's `default` so that leaves without + // their own example/default can still show a value (e.g. the rate + // limiters, whose defaults are whole-object defaults on the parent). + if let Some(props) = resolved.get("properties").and_then(Value::as_object) + && !props.is_empty() + { + let defaults = resolved.get("default").or(inherited); + let mut out = vec![format!("{pad}{name}:")]; + let mut first = true; + for (key, child) in props { + if skip.contains(&key.as_str()) { + continue; + } + let child_path = format!("{path}.{key}"); + let child_inherited = defaults.and_then(|d| d.get(key)); + let emitted = self.emit_field(key, child, indent + 2, &child_path, child_inherited); + if emitted.is_empty() { + continue; + } + if !first { + out.push(String::new()); + } + first = false; + out.extend(emitted); + } + return out; + } + + // Arrays with structured items. + if resolved.get("type").and_then(Value::as_str) == Some("array") + && let Some(items) = resolved.get("items") + { + let items = self.strip_null(items); + let item_path = format!("{path}[]"); + + let has_props = items + .get("properties") + .and_then(Value::as_object) + .is_some_and(|p| !p.is_empty()); + if has_props { + let mut out = vec![format!("{pad}{name}:")]; + out.extend(self.render_list_item(&items, indent + 2, &item_path)); + return out; + } + + if let Some(variants) = variants_of(&items) { + let mut out = vec![format!("{pad}{name}:")]; + out.extend(self.render_variants(&variants, indent + 2, &item_path)); + return out; + } + } + + // Leaf value: example, then default, then a commented placeholder. + self.emit_leaf(name, resolved, indent, path, inherited) + } + + /// Emit a leaf value using the first example, then the default (own or + /// inherited from a parent object's default), then a commented + /// placeholder (which also records a warning). + fn emit_leaf( + &mut self, + name: &str, + resolved: &Value, + indent: usize, + path: &str, + inherited: Option<&Value>, + ) -> Vec { + // A `const` is a fixed value: render it directly. + if let Some(constant) = resolved.get("const") { + return render_scalar(name, constant, resolved, indent); + } + + if let Some(example) = resolved + .get("examples") + .and_then(Value::as_array) + .and_then(|a| a.first()) + { + return render_scalar(name, example, resolved, indent); + } + + if let Some(default) = resolved.get("default") + && !default.is_null() + { + return render_scalar(name, default, resolved, indent); + } + + if let Some(inherited) = inherited + && !inherited.is_null() + { + return render_scalar(name, inherited, resolved, indent); + } + + let pad = " ".repeat(indent); + + // A field explicitly marked `commented` with no value to show renders + // as a bare commented-out key, without a placeholder or a warning. + if Self::x_doc_commented(resolved) { + return vec![format!("{pad}{name}:")]; + } + + self.warn(format!("missing example: {path}")); + vec![format!("{pad}#{name}: <{}>", placeholder_type(resolved))] + } + + /// Render a single list item (`- …`) whose type is an object with + /// properties, splicing the `- ` marker into the first emitted line. + fn render_list_item(&mut self, item: &Value, dash_indent: usize, path: &str) -> Vec { + let mut lines = Vec::new(); + if let Some(props) = item.get("properties").and_then(Value::as_object) { + // Emit `const` properties (an internally-tagged enum's tag, e.g. a + // resource's `name`) before the variant's other fields: the tag is + // what identifies the item, so it reads best first. + let (tags, fields): (Vec<_>, Vec<_>) = props + .iter() + .partition(|(_, child)| child.get("const").is_some()); + let mut first = true; + for (key, child) in tags.into_iter().chain(fields) { + let child_path = format!("{path}.{key}"); + let emitted = self.emit_field(key, child, dash_indent + 2, &child_path, None); + if emitted.is_empty() { + continue; + } + if !first { + lines.push(String::new()); + } + first = false; + lines.extend(emitted); + } + } + splice_dash(&mut lines, dash_indent); + lines + } + + /// Render one list item per variant of an `anyOf`/`oneOf`, each preceded by + /// its variant description as a comment. + fn render_variants( + &mut self, + variants: &[Value], + dash_indent: usize, + path: &str, + ) -> Vec { + let pad = " ".repeat(dash_indent); + let mut out = Vec::new(); + + for (index, variant) in variants.iter().enumerate() { + if index > 0 { + out.push(String::new()); + } + let resolved = self.strip_null(variant); + + if let Some(desc) = resolved.get("description").and_then(Value::as_str) { + out.extend(comment_block(&clean_description(desc), dash_indent)); + } + + let has_props = resolved + .get("properties") + .and_then(Value::as_object) + .is_some_and(|p| !p.is_empty()); + + if has_props { + out.extend(self.render_list_item(&resolved, dash_indent, path)); + } else if let Some(constant) = resolved.get("const").and_then(Value::as_str) { + out.push(format!("{pad}- {constant}")); + } else { + self.warn(format!("missing example: {path}")); + out.push(format!("{pad}- <{}>", placeholder_type(&resolved))); + } + } + + out + } +} + +// --- Free functions -------------------------------------------------------- + +/// Collapse runs of blank lines and ensure a single trailing newline. +fn finalize(lines: &[String]) -> String { + let mut output = String::new(); + let mut previous_blank = true; // suppresses a leading blank line + for line in lines { + let blank = line.is_empty(); + if blank && previous_blank { + continue; + } + previous_blank = blank; + output.push_str(line); + output.push('\n'); + } + while output.ends_with("\n\n") { + output.pop(); + } + output +} + +/// Strip the single leading space schemars keeps on each doc-comment line. +fn clean_description(desc: &str) -> String { + desc.lines() + .map(|line| line.strip_prefix(' ').unwrap_or(line)) + .collect::>() + .join("\n") + .trim_end() + .to_owned() +} + +/// Split a section description around a `` marker line. +fn split_more(desc: &str) -> (String, String) { + let mut before = Vec::new(); + let mut after = Vec::new(); + let mut seen = false; + for line in desc.lines() { + if line.trim() == "" { + seen = true; + continue; + } + if seen { + after.push(line); + } else { + before.push(line); + } + } + ( + before.join("\n").trim_end().to_owned(), + after.join("\n").trim().to_owned(), + ) +} + +/// Build the comment lines for a field: its description, wrapped, plus a +/// "Possible values" block for described string enums. +fn comment_lines(resolved: &Value, indent: usize) -> Vec { + let mut out = Vec::new(); + if let Some(desc) = resolved.get("description").and_then(Value::as_str) { + out.extend(comment_block(&clean_description(desc), indent)); + } + + if let Some(info) = enum_info(resolved) + && info.has_descriptions + { + let pad = " ".repeat(indent); + out.push(format!("{pad}# Possible values are:")); + for (value, desc) in &info.values { + let first = format!("{pad}# - `{value}`: "); + let cont = format!("{pad}# "); + let text = desc.as_deref().map(clean_description).unwrap_or_default(); + let words: Vec<&str> = text.split_whitespace().collect(); + out.extend(wrap_prefixed(&words, &first, &cont, 80)); + } + } + + out +} + +/// Turn a description into `# `-prefixed comment lines, wrapped to ~80 columns. +/// +/// Blank lines become a bare `#`. Lines that look like list items or are +/// indented are emitted verbatim (their markers/indentation preserved) rather +/// than being reflowed. +fn comment_block(desc: &str, indent: usize) -> Vec { + let pad = " ".repeat(indent); + let prefix = format!("{pad}# "); + let mut out = Vec::new(); + let mut paragraph: Vec<&str> = Vec::new(); + + let flush = |paragraph: &mut Vec<&str>, out: &mut Vec| { + if !paragraph.is_empty() { + let words: Vec<&str> = paragraph + .iter() + .flat_map(|l| l.split_whitespace()) + .collect(); + out.extend(wrap_prefixed(&words, &prefix, &prefix, 80)); + paragraph.clear(); + } + }; + + for line in desc.lines() { + if line.trim().is_empty() { + flush(&mut paragraph, &mut out); + out.push(format!("{pad}#")); + } else if is_structured(line) { + flush(&mut paragraph, &mut out); + out.push(format!("{pad}# {line}")); + } else { + paragraph.push(line); + } + } + flush(&mut paragraph, &mut out); + + out +} + +/// Whether a description line should be preserved verbatim (list item or +/// indented) rather than reflowed with its neighbours. +fn is_structured(line: &str) -> bool { + if line.starts_with(char::is_whitespace) { + return true; + } + let trimmed = line.trim_start(); + trimmed.starts_with("- ") || trimmed.starts_with("* ") +} + +/// Greedy word wrap into lines that start with `first`, continuing with `cont`, +/// aiming to keep each line at most `width` columns wide. +fn wrap_prefixed(words: &[&str], first: &str, cont: &str, width: usize) -> Vec { + if words.is_empty() { + return vec![first.trim_end().to_owned()]; + } + + let mut lines = Vec::new(); + let mut current = first.to_owned(); + let mut has_word = false; + + for word in words { + let projected = current.len() + usize::from(has_word) + word.len(); + if has_word && projected > width { + lines.push(std::mem::take(&mut current)); + current.push_str(cont); + current.push_str(word); + } else { + if has_word { + current.push(' '); + } + current.push_str(word); + } + has_word = true; + } + lines.push(current); + lines +} + +/// Comment out a rendered value line by inserting `#` at the line's own +/// indentation. +/// +/// Lines that are already comments (or already commented) are +/// left untouched, to avoid doubling up `#`s under a commented parent. +fn comment_out(line: &str) -> String { + if line.trim().is_empty() { + return line.to_owned(); + } + // Insert the `#` at the line's own indentation level, so that nested + // structures stay readable (`#key:` on the parent, ` #child: x` below). + let pos = line.len() - line.trim_start().len(); + let (lead, rest) = line.split_at(pos); + if rest.starts_with('#') { + return line.to_owned(); + } + format!("{lead}#{rest}") +} + +/// Splice a `- ` list marker into the first non-blank line of an item block. +fn splice_dash(lines: &mut [String], dash_indent: usize) { + if let Some(line) = lines.iter_mut().find(|l| !l.trim().is_empty()) { + let content = line.get(dash_indent + 2..).unwrap_or("").to_owned(); + *line = format!("{}- {content}", " ".repeat(dash_indent)); + } +} + +/// Re-indent a raw multi-line string so that its least-indented line sits at +/// `indent` columns. +fn reindent(text: &str, indent: usize) -> Vec { + let pad = " ".repeat(indent); + let min = text + .lines() + .filter(|l| !l.trim().is_empty()) + .map(|l| l.len() - l.trim_start().len()) + .min() + .unwrap_or(0); + text.lines() + .map(|line| { + if line.trim().is_empty() { + String::new() + } else { + format!("{pad}{}", &line[min..]) + } + }) + .collect() +} + +/// Extract the `anyOf`/`oneOf` variants of a node, if any. +fn variants_of(node: &Value) -> Option> { + node.get("oneOf") + .or_else(|| node.get("anyOf")) + .and_then(Value::as_array) + .cloned() +} + +/// Detect a string enum, either as a plain `enum` list or a `oneOf` of `const` +/// string variants, collecting per-variant descriptions where present. +fn enum_info(node: &Value) -> Option { + if let Some(values) = node.get("enum").and_then(Value::as_array) { + let values: Vec<(String, Option)> = values + .iter() + .filter_map(|v| v.as_str().map(|s| (s.to_owned(), None))) + .collect(); + if values.is_empty() { + return None; + } + return Some(EnumInfo { + values, + has_descriptions: false, + }); + } + + let variants = node.get("oneOf").and_then(Value::as_array)?; + let mut values = Vec::new(); + for variant in variants { + let constant = variant.get("const").and_then(Value::as_str)?; + let desc = variant + .get("description") + .and_then(Value::as_str) + .map(ToOwned::to_owned); + values.push((constant.to_owned(), desc)); + } + if values.is_empty() { + return None; + } + let has_descriptions = values.iter().any(|(_, d)| d.is_some()); + Some(EnumInfo { + values, + has_descriptions, + }) +} + +/// Render a scalar/inline/block leaf value as `name: ` lines. +fn render_scalar(name: &str, value: &Value, resolved: &Value, indent: usize) -> Vec { + let pad = " ".repeat(indent); + match value { + Value::Array(items) if items.is_empty() => vec![format!("{pad}{name}: []")], + // Strings containing a colon (URLs, mostly) are not valid plain + // scalars in a flow sequence — render those lists in block style. + Value::Array(items) + if items.iter().all(is_scalar) + && inline_len(items) <= 60 + && !items + .iter() + .any(|i| i.as_str().is_some_and(|s| s.contains(':'))) => + { + vec![format!("{pad}{name}: {}", inline_array(items))] + } + Value::Object(_) | Value::Array(_) => { + let mut out = vec![format!("{pad}{name}:")]; + out.extend(yaml_block(value, indent + 2)); + out + } + Value::String(text) if text.contains('\n') => { + let mut out = vec![format!("{pad}{name}: |")]; + let inner = " ".repeat(indent + 2); + for line in text.lines() { + out.push(format!("{inner}{line}")); + } + out + } + _ => { + let mut line = format!("{pad}{name}: {}", yaml_scalar(value)); + if let Some(trailing) = enum_trailing(resolved, value) { + line.push_str(&trailing); + } + vec![line] + } + } +} + +/// A trailing `# or a, b, c` comment listing the other values of an +/// undescribed enum, or `None` when it does not apply. +fn enum_trailing(resolved: &Value, value: &Value) -> Option { + let info = enum_info(resolved)?; + if info.has_descriptions { + return None; + } + let shown = value.as_str()?; + let others: Vec<&str> = info + .values + .iter() + .map(|(v, _)| v.as_str()) + .filter(|v| *v != shown) + .collect(); + // A trailing list only reads well for small enums; huge ones (e.g. the + // JSON Web Signature algorithms) are better left to the field's docs. + if others.is_empty() || others.len() > 6 { + return None; + } + Some(format!(" # or {}", others.join(", "))) +} + +fn is_scalar(value: &Value) -> bool { + !matches!(value, Value::Array(_) | Value::Object(_)) +} + +fn inline_len(items: &[Value]) -> usize { + inline_array(items).len() +} + +fn inline_array(items: &[Value]) -> String { + let rendered: Vec = items.iter().map(yaml_scalar).collect(); + format!("[{}]", rendered.join(", ")) +} + +/// Render a scalar JSON value as a single-line YAML token. +fn yaml_scalar(value: &Value) -> String { + match value { + Value::Null => "~".to_owned(), + Value::Bool(b) => b.to_string(), + // Display floats with at most 6 decimal places: whole-object defaults + // carry values like 1/1200 whose full f64 expansion is unreadable. + Value::Number(n) if n.as_f64().is_some_and(|f| f.fract() != 0.0) => { + let formatted = format!("{:.6}", n.as_f64().unwrap_or_default()); + formatted.trim_end_matches('0').to_owned() + } + Value::Number(n) => n.to_string(), + _ => serde_yaml::to_string(value) + .unwrap_or_default() + .trim_end() + .to_owned(), + } +} + +/// Render a JSON value as a block of YAML lines indented to `indent` columns. +fn yaml_block(value: &Value, indent: usize) -> Vec { + let yaml = serde_yaml::to_string(value).unwrap_or_default(); + reindent(&yaml, indent) +} + +/// A placeholder type name for a field with no example or default. +fn placeholder_type(resolved: &Value) -> &'static str { + let type_name = match resolved.get("type") { + Some(Value::String(t)) => Some(t.as_str()), + Some(Value::Array(types)) => types + .iter() + .filter_map(Value::as_str) + .find(|t| *t != "null"), + _ => None, + }; + + match type_name { + Some("integer") => "integer", + Some("number") => "number", + Some("boolean") => "boolean", + Some("array") => "array", + Some("object") => "object", + Some("string") => "string", + _ => { + if resolved.get("oneOf").is_some() + || resolved.get("anyOf").is_some() + || resolved.get("enum").is_some() + { + "string" + } else { + "value" + } + } + } +} + +/// Whether a section has any fields left to render once hoisted fields and +/// the like are removed. +fn section_has_body(node: &Value, skip: &[&str]) -> bool { + // Non-object sections (e.g. the `clients` array) always have a body. + let Some(props) = node.get("properties").and_then(Value::as_object) else { + return true; + }; + props.keys().any(|key| !skip.contains(&key.as_str())) +} diff --git a/misc/update.sh b/misc/update.sh index 5d5ce8499..27f7a31b5 100755 --- a/misc/update.sh +++ b/misc/update.sh @@ -1,4 +1,5 @@ #!/bin/sh +# Copyright 2026 Element Creations Ltd. # Copyright 2025 New Vector Ltd. # # SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial @@ -9,12 +10,14 @@ set -eu export SQLX_OFFLINE=1 BASE_DIR="$(dirname "$0")/.." CONFIG_SCHEMA="${BASE_DIR}/docs/config.schema.json" +CONFIG_REFERENCE="${BASE_DIR}/docs/reference/configuration.md" API_SCHEMA="${BASE_DIR}/docs/api/spec.json" GRAPHQL_SCHEMA="${BASE_DIR}/frontend/schema.graphql" POLICIES_SCHEMA="${BASE_DIR}/policies/schema/" set -x cargo run -p mas-config > "${CONFIG_SCHEMA}" +cargo run -p mas-config --bin docs > "${CONFIG_REFERENCE}" cargo run -p mas-handlers --bin graphql-schema > "${GRAPHQL_SCHEMA}" cargo run -p mas-handlers --bin api-schema > "${API_SCHEMA}" cargo run -p mas-i18n-scan -- --update "${BASE_DIR}/templates/" "${BASE_DIR}/translations/en.json"