forked from EduCraft/curriculum-project-hub
553 lines
20 KiB
Rust
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);
|
|
}
|
|
}
|