Files
curriculum-project-hub/crates/cph-schema/src/lib.rs
T

553 lines
20 KiB
Rust

//! `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
//! `<field>.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<Self> {
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<ScalarType>,
/// Allowed values from a JSON Schema `enum`, as JSON values.
allowed: Option<Vec<Json>>,
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<ContentField>,
scalar_fields: Vec<ScalarField>,
/// 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 `<field>.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<String> = 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<BTreeMap<&'static str, KindSchema>> = 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("<field>.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<Diagnostic> {
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<Diagnostic>) {
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<String> = 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 `<field>.<ext>` 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<Diagnostic>,
) {
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);
}
}