//! `cph-schema` — kind JSON Schemas (ADR-0006) + structural validation. //! //! Owned by **WU-3**. The schema layer reads each kind's declarative JSON //! Schema (built-in scalars plus the `content` extension type) and validates //! element instance data against it, including which `content` `.typ` files //! must exist. //! //! # The `content` extension convention //! //! ADR-0006 fixes that a kind's data schema is a declarative **JSON Schema** //! (draft 2020-12 here) with one extension type — `content` — whose value is a //! Typst source rather than inline data. JSON Schema has no native `content` //! type, so this crate marks a content field with a **custom keyword**: //! //! ```json //! { "type": "string", "x-cph-content": true } //! ``` //! //! The `x-cph-content: true` marker is the single convention this crate honors. //! A field carrying it is treated as a **content field**: it does NOT appear in //! `element.toml`; instead the validator checks that the sibling file //! `.typ` exists in the element's directory (ADR-0008). A required //! content field whose `.typ` file is missing yields a //! [`cph_diag::DiagCode::MissingContentFile`]; an *optional* content field //! (one not in the schema's `required` list, e.g. a lemma's `proof`) may be //! absent with no diagnostic. All other (non-`x-cph-content`) properties are //! **scalar** fields that live in `element.toml` and are validated structurally. //! //! The four stdlib kind schemas live as real JSON artifacts under //! `crates/cph-schema/schemas/{segment,example,lemma,sop}.json` and are //! compiled in via `include_str!`, parsed once on first use. //! //! # Validation approach //! //! The MVP scalar surface is intentionally tiny (a single optional string on //! `example`), so rather than pull in a full JSON Schema engine (and its //! version churn), this crate uses `serde_json` to parse the schemas and a //! small hand-rolled interpreter that understands exactly the keywords the //! stdlib schemas use: `properties`, `required`, `type`, `enum`, //! `additionalProperties`, plus the `x-cph-content` extension marker. This //! keeps the schemas as declarative artifacts (ADR-0006 intent) while keeping //! the validator dependency-light and fully under our control. use std::collections::BTreeMap; use std::sync::OnceLock; use cph_diag::{DiagCode, Diagnostic}; use cph_model::ElementDescriptor; use serde_json::Value as Json; /// The custom JSON Schema keyword marking a field as a `content` (Typst-source) /// field rather than a scalar. See the crate docs for the convention. const CONTENT_MARKER: &str = "x-cph-content"; /// The known stdlib kinds, in a stable order. const KNOWN_KINDS: &[&str] = &["segment", "example", "lemma", "sop"]; /// Raw schema JSON for each kind, embedded at compile time. const SCHEMA_SOURCES: &[(&str, &str)] = &[ ("segment", include_str!("../schemas/segment.json")), ("example", include_str!("../schemas/example.json")), ("lemma", include_str!("../schemas/lemma.json")), ("sop", include_str!("../schemas/sop.json")), ]; /// The scalar `type` a property declares (the subset the stdlib schemas use). #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum ScalarType { String, Number, Integer, Boolean, } impl ScalarType { fn from_json(s: &str) -> Option { match s { "string" => Some(ScalarType::String), "number" => Some(ScalarType::Number), "integer" => Some(ScalarType::Integer), "boolean" => Some(ScalarType::Boolean), _ => None, } } fn label(self) -> &'static str { match self { ScalarType::String => "string", ScalarType::Number => "number", ScalarType::Integer => "integer", ScalarType::Boolean => "boolean", } } /// Whether `value` (a scalar coming from `element.toml`, already converted /// to JSON) satisfies this declared type. fn matches(self, value: &Json) -> bool { match self { ScalarType::String => value.is_string(), ScalarType::Number => value.is_number(), ScalarType::Integer => value.is_i64() || value.is_u64(), ScalarType::Boolean => value.is_boolean(), } } } /// One scalar property of a kind (a field that lives in `element.toml`). #[derive(Debug, Clone)] struct ScalarField { name: String, /// Declared `type`, if any. `None` means "unconstrained" (we still accept /// the field but apply no type check). ty: Option, /// Allowed values from a JSON Schema `enum`, as JSON values. allowed: Option>, required: bool, } /// One content property of a kind (a field whose value is a sibling content /// file, marked with `x-cph-content`). The file extension is determined by the /// marker's value: `true` (or `"typ"`) → `.typ`; any other string → that /// extension (e.g. `"md"` → `.md`, ADR-0015 markdown content surfaces). #[derive(Debug, Clone)] struct ContentField { name: String, required: bool, /// The content file's extension without the dot (e.g. `"typ"`, `"md"`). ext: String, } /// A parsed kind schema: the content fields (which drive `.typ` existence /// checks) and the scalar fields (validated against `element.toml`). #[derive(Debug, Clone)] pub struct KindSchema { kind: String, content_fields: Vec, scalar_fields: Vec, /// Whether the schema forbids properties beyond those declared /// (`additionalProperties: false`). deny_additional: bool, } impl KindSchema { /// The kind name this schema describes (e.g. `"example"`). pub fn kind(&self) -> &str { &self.kind } /// The names of the `content` fields (those requiring a `.typ` /// file), in schema order. pub fn content_field_names(&self) -> Vec<&str> { self.content_fields .iter() .map(|f| f.name.as_str()) .collect() } /// The names of the scalar fields (those living in `element.toml`), in /// schema order. pub fn scalar_field_names(&self) -> Vec<&str> { self.scalar_fields.iter().map(|f| f.name.as_str()).collect() } /// Parse a kind schema from its raw JSON source. Panics on a malformed /// schema — schemas are compile-time artifacts owned by this crate, so a /// bad one is a build-time bug, not a runtime condition. fn parse(kind: &str, src: &str) -> Self { let root: Json = serde_json::from_str(src) .unwrap_or_else(|e| panic!("kind schema for '{kind}' is not valid JSON: {e}")); let deny_additional = root .get("additionalProperties") .and_then(Json::as_bool) .map(|b| !b) .unwrap_or(false); let required: Vec = root .get("required") .and_then(Json::as_array) .map(|a| { a.iter() .filter_map(|v| v.as_str().map(str::to_string)) .collect() }) .unwrap_or_default(); let is_required = |name: &str| required.iter().any(|r| r == name); let mut content_fields = Vec::new(); let mut scalar_fields = Vec::new(); let props = root .get("properties") .and_then(Json::as_object) .unwrap_or_else(|| panic!("kind schema for '{kind}' has no `properties` object")); for (name, prop) in props { // The `x-cph-content` marker selects a content field and fixes its // file extension. `true` (the legacy form) and the string `"typ"` // both mean a `.typ` file; any other string is taken as the // extension (e.g. `"md"` → `.md` for markdown content surfaces, // ADR-0015). Absent ⇒ a scalar field. let ext = match prop.get(CONTENT_MARKER) { Some(Json::Bool(true)) => Some("typ".to_string()), Some(Json::String(s)) => Some(s.clone()), _ => None, }; if let Some(ext) = ext { content_fields.push(ContentField { name: name.clone(), required: is_required(name), ext, }); } else { let ty = prop .get("type") .and_then(Json::as_str) .and_then(ScalarType::from_json); let allowed = prop .get("enum") .and_then(Json::as_array) .map(|a| a.to_vec()); scalar_fields.push(ScalarField { name: name.clone(), ty, allowed, required: is_required(name), }); } } KindSchema { kind: kind.to_string(), content_fields, scalar_fields, deny_additional, } } } /// The lazily-built registry mapping kind name → parsed schema. fn registry() -> &'static BTreeMap<&'static str, KindSchema> { static REGISTRY: OnceLock> = OnceLock::new(); REGISTRY.get_or_init(|| { SCHEMA_SOURCES .iter() .map(|(kind, src)| (*kind, KindSchema::parse(kind, src))) .collect() }) } /// Look up the parsed [`KindSchema`] for `kind`, or `None` if it is unknown. pub fn schema_for(kind: &str) -> Option<&'static KindSchema> { registry().get(kind) } /// The known stdlib kind names (`["segment", "example", "lemma", "sop"]`), /// for use by the orchestrator (`cph-check`). pub fn known_kinds() -> &'static [&'static str] { KNOWN_KINDS } /// Validate an element instance against its kind's schema. /// /// Self-contained: looks up the schema by `desc.kind` and, if the kind is /// unknown, returns a single [`cph_diag::DiagCode::UnknownKind`] diagnostic. /// Otherwise it checks, in order: /// /// 1. **Scalar fields** (`desc.scalars`, a `toml::Table`) against the /// non-content part of the schema — required-ness, declared `type`, and /// `enum`. Violations are [`cph_diag::DiagCode::SchemaViolation`]. Under an /// `additionalProperties: false` schema, an unexpected scalar key is also a /// `SchemaViolation`. /// 2. **Content fields**: for each required content field, that the sibling /// file `desc.dir.join(".typ")` exists; if not, a /// [`cph_diag::DiagCode::MissingContentFile`]. Optional content fields may be /// absent with no diagnostic. /// /// Every diagnostic carries a fix `hint`. `MissingContentFile` names the /// expected path in its message; `span` is left `None` (there is no line/col /// for a file that does not exist). pub fn validate(desc: &ElementDescriptor) -> Vec { let Some(schema) = schema_for(&desc.kind) else { return vec![Diagnostic::error( DiagCode::UnknownKind, format!("unknown element kind '{}'", desc.kind), ) .with_hint(format!("valid kinds are: {}", KNOWN_KINDS.join(", ")))]; }; let mut diags = Vec::new(); validate_scalars(schema, desc, &mut diags); validate_content_files(schema, desc, &mut diags); diags } /// Validate the scalar fields in `desc.scalars` against the schema's scalar /// part. fn validate_scalars(schema: &KindSchema, desc: &ElementDescriptor, diags: &mut Vec) { let scalars = &desc.scalars; // Required scalar fields must be present. for field in &schema.scalar_fields { if field.required && !scalars.contains_key(&field.name) { diags.push( Diagnostic::error( DiagCode::SchemaViolation, format!( "element of kind '{}' is missing required field '{}'", schema.kind, field.name ), ) .with_hint(format!("add `{} = ...` to element.toml", field.name)), ); } } // Each present scalar: type / enum checks, and (if known) reject unexpected // keys when additionalProperties is false. Content fields must never appear // in element.toml. for (key, value) in scalars { if let Some(field) = schema.content_fields.iter().find(|f| f.name == *key) { diags.push( Diagnostic::error( DiagCode::SchemaViolation, format!( "field '{}' of kind '{}' is a content field and must not appear in element.toml", key, schema.kind ), ) .with_hint(format!( "remove '{key}' from element.toml; put its content in the sibling file '{key}.{}'", field.ext )), ); continue; } let Some(field) = schema.scalar_fields.iter().find(|f| f.name == *key) else { if schema.deny_additional { diags.push( Diagnostic::error( DiagCode::SchemaViolation, format!("unexpected field '{}' for kind '{}'", key, schema.kind), ) .with_hint(format!( "remove '{key}' from element.toml (it is not part of the '{}' schema)", schema.kind )), ); } continue; }; let json = toml_to_json(value); if let Some(ty) = field.ty { if !ty.matches(&json) { diags.push( Diagnostic::error( DiagCode::SchemaViolation, format!( "field '{}' of kind '{}' must be a {}, got {}", key, schema.kind, ty.label(), json_type_label(&json) ), ) .with_hint(format!( "set '{key}' to a {} value in element.toml", ty.label() )), ); continue; } } if let Some(allowed) = &field.allowed { if !allowed.contains(&json) { let choices: Vec = allowed.iter().map(render_json_choice).collect(); diags.push( Diagnostic::error( DiagCode::SchemaViolation, format!( "field '{}' of kind '{}' has value {} which is not one of the allowed values", key, schema.kind, render_json_choice(&json) ), ) .with_hint(format!("'{key}' must be one of: {}", choices.join(", "))), ); } } } } /// For each required content field, check that its `.` file exists /// in the element directory (`.typ` for typst content, `.md` for markdown /// content surfaces — ADR-0015). fn validate_content_files( schema: &KindSchema, desc: &ElementDescriptor, diags: &mut Vec, ) { for field in &schema.content_fields { if !field.required { // Optional content (e.g. lemma's `proof`): absence is fine. continue; } let file_name = format!("{}.{}", field.name, field.ext); let path = desc.dir.join(&file_name); if !path.is_file() { diags.push( Diagnostic::error( DiagCode::MissingContentFile, format!( "kind '{}' requires the content file '{}', which is missing", schema.kind, path.display() ), ) .with_hint(format!( "create '{file_name}' in the element folder with the {} source for '{}'", ext_label(&field.ext), field.name )), ); } } } /// A human label for a content-file extension, used in diagnostic hints. fn ext_label(ext: &str) -> &'static str { match ext { "md" => "markdown", "typ" => "typst", _ => "content", } } /// Convert a `toml::Value` scalar into the equivalent `serde_json::Value` for /// type checking. Composite TOML values (arrays / tables) are converted /// structurally too, though the MVP stdlib schemas only use scalars. fn toml_to_json(value: &toml::Value) -> Json { match value { toml::Value::String(s) => Json::String(s.clone()), toml::Value::Integer(i) => Json::from(*i), toml::Value::Float(f) => Json::from(*f), toml::Value::Boolean(b) => Json::Bool(*b), toml::Value::Datetime(dt) => Json::String(dt.to_string()), toml::Value::Array(arr) => Json::Array(arr.iter().map(toml_to_json).collect()), toml::Value::Table(tbl) => Json::Object( tbl.iter() .map(|(k, v)| (k.clone(), toml_to_json(v))) .collect(), ), } } /// A short label for a JSON value's runtime type, for diagnostic messages. fn json_type_label(value: &Json) -> &'static str { match value { Json::Null => "null", Json::Bool(_) => "boolean", Json::Number(n) => { if n.is_i64() || n.is_u64() { "integer" } else { "number" } } Json::String(_) => "string", Json::Array(_) => "array", Json::Object(_) => "object", } } /// Render a JSON value for an enum-choice list (strings unquoted-ish, others as /// JSON). fn render_json_choice(value: &Json) -> String { match value { Json::String(s) => format!("\"{s}\""), other => other.to_string(), } } #[cfg(test)] mod tests { use super::*; #[test] fn registry_has_all_known_kinds() { for kind in known_kinds() { assert!(schema_for(kind).is_some(), "missing schema for {kind}"); } assert!(schema_for("frob").is_none()); } #[test] fn example_field_split() { let s = schema_for("example").unwrap(); // `problem`/`solution` are typst content; `slides`/`transcript` are the // optional markdown content surfaces (ADR-0015); `source` is a scalar. // (serde_json's Map is a BTreeMap without the `preserve_order` feature, // so content field names come back alphabetically sorted.) assert_eq!( s.content_field_names(), vec!["problem", "slides", "solution", "transcript"] ); assert_eq!(s.scalar_field_names(), vec!["source"]); } #[test] fn lemma_proof_is_optional_content() { let s = schema_for("lemma").unwrap(); let proof = s.content_fields.iter().find(|f| f.name == "proof").unwrap(); assert!(!proof.required); let stmt = s.content_fields.iter().find(|f| f.name == "stmt").unwrap(); assert!(stmt.required); } #[test] fn markdown_content_fields_carry_md_ext() { // ADR-0015: `slides`/`transcript` are markdown content fields (`x-cph-content: "md"`), // distinct from the `.typ` content fields. let s = schema_for("segment").unwrap(); let slides = s .content_fields .iter() .find(|f| f.name == "slides") .unwrap(); assert_eq!(slides.ext, "md"); assert!(!slides.required); let transcript = s .content_fields .iter() .find(|f| f.name == "transcript") .unwrap(); assert_eq!(transcript.ext, "md"); assert!(!transcript.required); // Legacy typst content still resolves to `.typ`. let textbook = s .content_fields .iter() .find(|f| f.name == "textbook") .unwrap(); assert_eq!(textbook.ext, "typ"); assert!(textbook.required); } }